mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-21 17:54:23 +01:00
wip: better desktop file status state and timeline
This commit is contained in:
@@ -435,7 +435,7 @@ function transformerUnifiedDiff(): ShikiTransformer {
|
|||||||
out.push(s)
|
out.push(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
return out.join("\n")
|
return out.join("\n").trimEnd()
|
||||||
},
|
},
|
||||||
code(node) {
|
code(node) {
|
||||||
if (isDiff) this.addClassToHast(node, "code-diff")
|
if (isDiff) this.addClassToHast(node, "code-diff")
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ function strip(text: string): string {
|
|||||||
const match = text.match(wrappedRe)
|
const match = text.match(wrappedRe)
|
||||||
return match ? match[2] : text
|
return match ? match[2] : text
|
||||||
}
|
}
|
||||||
|
export function Markdown(props: { text: string; class?: string }) {
|
||||||
export default function Markdown(props: { text: string; class?: string }) {
|
|
||||||
const marked = useMarked()
|
const marked = useMarked()
|
||||||
const [html] = createResource(
|
const [html] = createResource(
|
||||||
() => strip(props.text),
|
() => strip(props.text),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useLocal, useSync } from "@/context"
|
import { useLocal, useSync } from "@/context"
|
||||||
import { Collapsible, Icon, type IconProps } from "@/ui"
|
import { Collapsible, Icon } from "@/ui"
|
||||||
import type { Part, ToolPart } from "@opencode-ai/sdk"
|
import type { Part, ToolPart } from "@opencode-ai/sdk"
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
import {
|
import {
|
||||||
@@ -13,58 +13,14 @@ import {
|
|||||||
type ParentProps,
|
type ParentProps,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
|
Show,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { getFilename } from "@/utils"
|
import { getFilename } from "@/utils"
|
||||||
import Markdown from "./markdown"
|
import { Markdown } from "./markdown"
|
||||||
import { Code } from "./code"
|
import { Code } from "./code"
|
||||||
import { createElementSize } from "@solid-primitives/resize-observer"
|
import { createElementSize } from "@solid-primitives/resize-observer"
|
||||||
import { createScrollPosition } from "@solid-primitives/scroll"
|
import { createScrollPosition } from "@solid-primitives/scroll"
|
||||||
|
|
||||||
function TimelineIcon(props: { name: IconProps["name"]; class?: string }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"relative flex flex-none self-start items-center justify-center bg-background h-6 w-6": true,
|
|
||||||
[props.class ?? ""]: !!props.class,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name={props.name} class="text-text/40" size={18} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CollapsibleTimelineIcon(props: { name: IconProps["name"]; class?: string }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TimelineIcon
|
|
||||||
name={props.name}
|
|
||||||
class={`group-hover/li:hidden group-has-[[data-expanded]]/li:hidden ${props.class ?? ""}`}
|
|
||||||
/>
|
|
||||||
<TimelineIcon
|
|
||||||
name="chevron-right"
|
|
||||||
class={`hidden group-hover/li:flex group-has-[[data-expanded]]/li:hidden ${props.class ?? ""}`}
|
|
||||||
/>
|
|
||||||
<TimelineIcon name="chevron-down" class={`hidden group-has-[[data-expanded]]/li:flex ${props.class ?? ""}`} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolIcon(props: { part: ToolPart }) {
|
|
||||||
return (
|
|
||||||
<Switch fallback={<TimelineIcon name="hammer" />}>
|
|
||||||
<Match when={props.part.tool === "read"}>
|
|
||||||
<TimelineIcon name="file" />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.part.tool === "edit"}>
|
|
||||||
<CollapsibleTimelineIcon name="pencil" />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.part.tool === "write"}>
|
|
||||||
<CollapsibleTimelineIcon name="file-plus" />
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Part(props: ParentProps & ComponentProps<"div">) {
|
function Part(props: ParentProps & ComponentProps<"div">) {
|
||||||
const [local, others] = splitProps(props, ["class", "classList", "children"])
|
const [local, others] = splitProps(props, ["class", "classList", "children"])
|
||||||
return (
|
return (
|
||||||
@@ -97,9 +53,13 @@ function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ReadToolPart(props: { part: ToolPart }) {
|
function ReadToolPart(props: { part: ToolPart }) {
|
||||||
|
const sync = useSync()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Match when={props.part.state.status === "pending"}>
|
||||||
|
<Part>Reading file...</Part>
|
||||||
|
</Match>
|
||||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||||
{(state) => {
|
{(state) => {
|
||||||
const path = state().input["filePath"] as string
|
const path = state().input["filePath"] as string
|
||||||
@@ -110,13 +70,27 @@ function ReadToolPart(props: { part: ToolPart }) {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||||
|
{(state) => (
|
||||||
|
<div>
|
||||||
|
<Part>
|
||||||
|
<span class="text-text-muted">Read</span> {getFilename(state().input["filePath"] as string)}
|
||||||
|
</Part>
|
||||||
|
<div class="text-error">{sync.sanitize(state().error)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditToolPart(props: { part: ToolPart }) {
|
function EditToolPart(props: { part: ToolPart }) {
|
||||||
|
const sync = useSync()
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Match when={props.part.state.status === "pending"}>
|
||||||
|
<Part>Preparing edit...</Part>
|
||||||
|
</Match>
|
||||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||||
{(state) => (
|
{(state) => (
|
||||||
<CollapsiblePart
|
<CollapsiblePart
|
||||||
@@ -135,13 +109,30 @@ function EditToolPart(props: { part: ToolPart }) {
|
|||||||
</CollapsiblePart>
|
</CollapsiblePart>
|
||||||
)}
|
)}
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||||
|
{(state) => (
|
||||||
|
<CollapsiblePart
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<span class="text-text-muted">Edit</span> {getFilename(state().input["filePath"] as string)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="text-error">{sync.sanitize(state().error)}</div>
|
||||||
|
</CollapsiblePart>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function WriteToolPart(props: { part: ToolPart }) {
|
function WriteToolPart(props: { part: ToolPart }) {
|
||||||
|
const sync = useSync()
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Match when={props.part.state.status === "pending"}>
|
||||||
|
<Part>Preparing write...</Part>
|
||||||
|
</Match>
|
||||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||||
{(state) => (
|
{(state) => (
|
||||||
<CollapsiblePart
|
<CollapsiblePart
|
||||||
@@ -155,35 +146,95 @@ function WriteToolPart(props: { part: ToolPart }) {
|
|||||||
</CollapsiblePart>
|
</CollapsiblePart>
|
||||||
)}
|
)}
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||||
|
{(state) => (
|
||||||
|
<div>
|
||||||
|
<Part>
|
||||||
|
<span class="text-text-muted">Write</span> {getFilename(state().input["filePath"] as string)}
|
||||||
|
</Part>
|
||||||
|
<div class="text-error">{sync.sanitize(state().error)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BashToolPart(props: { part: ToolPart }) {
|
||||||
|
const sync = useSync()
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Match when={props.part.state.status === "pending"}>
|
||||||
|
<Part>Writing shell command...</Part>
|
||||||
|
</Match>
|
||||||
|
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||||
|
{(state) => (
|
||||||
|
<CollapsiblePart
|
||||||
|
defaultOpen
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<span class="text-text-muted">Run command:</span> {state().input["command"]}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Markdown text={`\`\`\`command\n${state().input["command"]}\n${state().output}\`\`\``} />
|
||||||
|
</CollapsiblePart>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||||
|
{(state) => (
|
||||||
|
<CollapsiblePart
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<span class="text-text-muted">Shell</span> {state().input["command"]}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="text-error">{sync.sanitize(state().error)}</div>
|
||||||
|
</CollapsiblePart>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolPart(props: { part: ToolPart }) {
|
function ToolPart(props: { part: ToolPart }) {
|
||||||
|
// read
|
||||||
|
// edit
|
||||||
|
// write
|
||||||
|
// bash
|
||||||
|
// ls
|
||||||
|
// glob
|
||||||
|
// grep
|
||||||
|
// todowrite
|
||||||
|
// todoread
|
||||||
|
// webfetch
|
||||||
|
// websearch
|
||||||
|
// patch
|
||||||
|
// task
|
||||||
return (
|
return (
|
||||||
<Switch
|
<div class="min-w-0 flex-auto text-xs">
|
||||||
fallback={
|
<Switch
|
||||||
<div class="flex-auto min-w-0 text-xs">
|
fallback={
|
||||||
{props.part.type}:{props.part.tool}
|
<span>
|
||||||
</div>
|
{props.part.type}:{props.part.tool}
|
||||||
}
|
</span>
|
||||||
>
|
}
|
||||||
<Match when={props.part.tool === "read"}>
|
>
|
||||||
<div class="min-w-0 flex-auto">
|
<Match when={props.part.tool === "read"}>
|
||||||
<ReadToolPart part={props.part} />
|
<ReadToolPart part={props.part} />
|
||||||
</div>
|
</Match>
|
||||||
</Match>
|
<Match when={props.part.tool === "edit"}>
|
||||||
<Match when={props.part.tool === "edit"}>
|
|
||||||
<div class="min-w-0 flex-auto">
|
|
||||||
<EditToolPart part={props.part} />
|
<EditToolPart part={props.part} />
|
||||||
</div>
|
</Match>
|
||||||
</Match>
|
<Match when={props.part.tool === "write"}>
|
||||||
<Match when={props.part.tool === "write"}>
|
|
||||||
<div class="min-w-0 flex-auto">
|
|
||||||
<WriteToolPart part={props.part} />
|
<WriteToolPart part={props.part} />
|
||||||
</div>
|
</Match>
|
||||||
</Match>
|
<Match when={props.part.tool === "bash"}>
|
||||||
</Switch>
|
<BashToolPart part={props.part} />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +247,7 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||||||
const scroll = createScrollPosition(scrollElement)
|
const scroll = createScrollPosition(scrollElement)
|
||||||
|
|
||||||
onMount(() => sync.session.sync(props.session))
|
onMount(() => sync.session.sync(props.session))
|
||||||
|
const session = createMemo(() => sync.session.get(props.session))
|
||||||
const messages = createMemo(() => sync.data.message[props.session] ?? [])
|
const messages = createMemo(() => sync.data.message[props.session] ?? [])
|
||||||
const working = createMemo(() => {
|
const working = createMemo(() => {
|
||||||
const last = messages()[messages().length - 1]
|
const last = messages()[messages().length - 1]
|
||||||
@@ -285,60 +337,33 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||||||
<div
|
<div
|
||||||
ref={setRoot}
|
ref={setRoot}
|
||||||
classList={{
|
classList={{
|
||||||
"p-4 select-text flex flex-col gap-y-8": true,
|
"p-4 select-text flex flex-col gap-y-1": true,
|
||||||
[props.class ?? ""]: !!props.class,
|
[props.class ?? ""]: !!props.class,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<For each={messages()}>
|
<ul role="list" class="flex flex-col gap-1">
|
||||||
{(message) => (
|
<For each={messages()}>
|
||||||
<ul role="list" class="space-y-2">
|
{(message) => (
|
||||||
<For each={sync.data.part[message.id]?.filter(valid)}>
|
<For each={sync.data.part[message.id]?.filter(valid)}>
|
||||||
{(part) => (
|
{(part) => (
|
||||||
<li classList={{ "relative group/li flex gap-x-4 min-w-0 w-full": true }}>
|
<li class="group/li">
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"absolute top-0 left-0 flex w-6 justify-center": true,
|
|
||||||
"last:h-10 not-last:-bottom-10": true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="w-px bg-border-subtle" />
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
fallback={
|
|
||||||
<div class="m-0.5 relative flex size-5 flex-none items-center justify-center bg-background">
|
|
||||||
<div class="size-1 rounded-full bg-text/10 ring ring-text/20" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Match when={part.type === "text"}>
|
|
||||||
<Switch>
|
|
||||||
<Match when={message.role === "user"}>
|
|
||||||
<TimelineIcon name="avatar-square" />
|
|
||||||
</Match>
|
|
||||||
<Match when={message.role === "assistant"}>
|
|
||||||
<TimelineIcon name="sparkles" />
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Match>
|
|
||||||
<Match when={part.type === "reasoning"}>
|
|
||||||
<CollapsibleTimelineIcon name="brain" />
|
|
||||||
</Match>
|
|
||||||
<Match when={part.type === "tool" && part}>{(part) => <ToolIcon part={part()} />}</Match>
|
|
||||||
</Switch>
|
|
||||||
<Switch fallback={<div class="flex-auto min-w-0 text-xs mt-1 text-left">{part.type}</div>}>
|
<Switch fallback={<div class="flex-auto min-w-0 text-xs mt-1 text-left">{part.type}</div>}>
|
||||||
<Match when={part.type === "text" && part}>
|
<Match when={part.type === "text" && part}>
|
||||||
{(part) => (
|
{(part) => (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={message.role === "user"}>
|
<Match when={message.role === "user"}>
|
||||||
<div class="w-full flex flex-col items-end justify-stretch gap-y-1.5 min-w-0">
|
<div class="w-full flex flex-col items-end justify-stretch gap-y-1.5 min-w-0 mt-5 group-first/li:mt-0">
|
||||||
<p class="w-full rounded-md p-3 ring-1 ring-text/15 ring-inset text-xs bg-background-panel">
|
<p class="w-full rounded-md p-3 ring-1 ring-text/15 ring-inset text-xs bg-background-panel">
|
||||||
<span class="font-medium text-text whitespace-pre-wrap break-words">{part().text}</span>
|
<span class="font-medium text-text whitespace-pre-wrap break-words">{part().text}</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-text-muted">12:07pm · adam</p>
|
<p class="text-xs text-text-muted">
|
||||||
|
{DateTime.fromMillis(message.time.created).toRelative()} ·{" "}
|
||||||
|
{sync.data.config.username ?? "user"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={message.role === "assistant"}>
|
<Match when={message.role === "assistant"}>
|
||||||
<Markdown text={part().text} class="text-text" />
|
<Markdown text={sync.sanitize(part().text)} class="text-text mt-1" />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)}
|
)}
|
||||||
@@ -347,9 +372,11 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||||||
{(part) => (
|
{(part) => (
|
||||||
<CollapsiblePart
|
<CollapsiblePart
|
||||||
title={
|
title={
|
||||||
<>
|
<Switch fallback={<span class="text-text-muted">Thinking</span>}>
|
||||||
<span class="text-text-muted">Thought</span> for {duration(part())}s
|
<Match when={part().time.end}>
|
||||||
</>
|
<span class="text-text-muted">Thought</span> for {duration(part())}s
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Markdown text={part().text} />
|
<Markdown text={part().text} />
|
||||||
@@ -361,9 +388,84 @@ export default function SessionTimeline(props: { session: string; class?: string
|
|||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</ul>
|
)}
|
||||||
)}
|
</For>
|
||||||
</For>
|
</ul>
|
||||||
|
<Show when={false}>
|
||||||
|
<Collapsible defaultOpen={false}>
|
||||||
|
<Collapsible.Trigger>
|
||||||
|
<div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted">
|
||||||
|
<Icon name="file-code" size={16} />
|
||||||
|
<span>Raw Session Data</span>
|
||||||
|
<Collapsible.Arrow size={18} class="text-text-muted" />
|
||||||
|
</div>
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
<Collapsible.Content class="mt-5">
|
||||||
|
<ul role="list" class="space-y-2">
|
||||||
|
<li>
|
||||||
|
<Collapsible>
|
||||||
|
<Collapsible.Trigger>
|
||||||
|
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||||
|
<Icon name="file-code" size={16} />
|
||||||
|
<span>session</span>
|
||||||
|
<Collapsible.Arrow size={18} class="text-text-muted" />
|
||||||
|
</div>
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
<Collapsible.Content>
|
||||||
|
<Code path="session.json" code={JSON.stringify(session(), null, 2)} class="[&_code]:pb-0!" />
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible>
|
||||||
|
</li>
|
||||||
|
<For each={messages()}>
|
||||||
|
{(message) => (
|
||||||
|
<>
|
||||||
|
<li>
|
||||||
|
<Collapsible>
|
||||||
|
<Collapsible.Trigger>
|
||||||
|
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||||
|
<Icon name="file-code" size={16} />
|
||||||
|
<span>{message.role === "user" ? "user" : "assistant"}</span>
|
||||||
|
<Collapsible.Arrow size={18} class="text-text-muted" />
|
||||||
|
</div>
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
<Collapsible.Content>
|
||||||
|
<Code
|
||||||
|
path={message.id + ".json"}
|
||||||
|
code={JSON.stringify(message, null, 2)}
|
||||||
|
class="[&_code]:pb-0!"
|
||||||
|
/>
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible>
|
||||||
|
</li>
|
||||||
|
<For each={sync.data.part[message.id]?.filter(valid)}>
|
||||||
|
{(part) => (
|
||||||
|
<li>
|
||||||
|
<Collapsible>
|
||||||
|
<Collapsible.Trigger>
|
||||||
|
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||||
|
<Icon name="file-code" size={16} />
|
||||||
|
<span>{part.type}</span>
|
||||||
|
<Collapsible.Arrow size={18} class="text-text-muted" />
|
||||||
|
</div>
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
<Collapsible.Content>
|
||||||
|
<Code
|
||||||
|
path={message.id + "." + part.id + ".json"}
|
||||||
|
code={JSON.stringify(part, null, 2)}
|
||||||
|
class="[&_code]:pb-0!"
|
||||||
|
/>
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
|
import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
|
||||||
import { uniqueBy } from "remeda"
|
import { uniqueBy } from "remeda"
|
||||||
import type { FileContent, FileNode, Model, Provider } from "@opencode-ai/sdk"
|
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
|
||||||
import { useSDK, useEvent, useSync } from "@/context"
|
import { useSDK, useEvent, useSync } from "@/context"
|
||||||
|
|
||||||
export type LocalFile = FileNode &
|
export type LocalFile = FileNode &
|
||||||
@@ -15,6 +15,7 @@ export type LocalFile = FileNode &
|
|||||||
view: "raw" | "diff-unified" | "diff-split"
|
view: "raw" | "diff-unified" | "diff-split"
|
||||||
folded: string[]
|
folded: string[]
|
||||||
selectedChange: number
|
selectedChange: number
|
||||||
|
status: FileStatus
|
||||||
}>
|
}>
|
||||||
export type TextSelection = LocalFile["selection"]
|
export type TextSelection = LocalFile["selection"]
|
||||||
export type View = LocalFile["view"]
|
export type View = LocalFile["view"]
|
||||||
@@ -126,9 +127,33 @@ function init() {
|
|||||||
const opened = createMemo(() => store.opened.map((x) => store.node[x]))
|
const opened = createMemo(() => store.opened.map((x) => store.node[x]))
|
||||||
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||||
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
||||||
const status = (path: string) => sync.data.changes.find((f) => f.path === path)
|
|
||||||
|
createEffect((prev: FileStatus[]) => {
|
||||||
|
const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
|
||||||
|
for (const p of removed) {
|
||||||
|
setStore(
|
||||||
|
"node",
|
||||||
|
p.path,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.status = undefined
|
||||||
|
draft.view = "raw"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
load(p.path)
|
||||||
|
}
|
||||||
|
for (const p of sync.data.changes) {
|
||||||
|
if (store.node[p.path] === undefined) {
|
||||||
|
fetch(p.path).then(() => setStore("node", p.path, "status", p))
|
||||||
|
} else {
|
||||||
|
setStore("node", p.path, "status", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sync.data.changes
|
||||||
|
}, sync.data.changes)
|
||||||
|
|
||||||
const changed = (path: string) => {
|
const changed = (path: string) => {
|
||||||
|
const node = store.node[path]
|
||||||
|
if (node?.status) return true
|
||||||
const set = changeset()
|
const set = changeset()
|
||||||
if (set.has(path)) return true
|
if (set.has(path)) return true
|
||||||
for (const p of set) {
|
for (const p of set) {
|
||||||
@@ -138,24 +163,17 @@ function init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resetNode = (path: string) => {
|
const resetNode = (path: string) => {
|
||||||
setStore("node", path, {
|
setStore("node", path, undefined!)
|
||||||
loaded: undefined,
|
|
||||||
pinned: undefined,
|
|
||||||
content: undefined,
|
|
||||||
selection: undefined,
|
|
||||||
scrollTop: undefined,
|
|
||||||
folded: undefined,
|
|
||||||
view: undefined,
|
|
||||||
selectedChange: undefined,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
|
||||||
|
|
||||||
const load = async (path: string) => {
|
const load = async (path: string) => {
|
||||||
const relative = path.replace(sync.data.path.directory + "/", "")
|
const relativePath = relative(path)
|
||||||
sdk.file.read({ query: { path: relative } }).then((x) => {
|
sdk.file.read({ query: { path: relativePath } }).then((x) => {
|
||||||
setStore(
|
setStore(
|
||||||
"node",
|
"node",
|
||||||
relative,
|
relativePath,
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft.loaded = true
|
draft.loaded = true
|
||||||
draft.content = x.data
|
draft.content = x.data
|
||||||
@@ -164,28 +182,31 @@ function init() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
|
const fetch = async (path: string) => {
|
||||||
const relative = path.replace(sync.data.path.directory + "/", "")
|
const relativePath = relative(path)
|
||||||
if (!store.node[relative]) {
|
const parent = relativePath.split("/").slice(0, -1).join("/")
|
||||||
const parent = relative.split("/").slice(0, -1).join("/")
|
if (parent) {
|
||||||
if (parent) {
|
await list(parent)
|
||||||
await list(parent)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
|
||||||
|
const relativePath = relative(path)
|
||||||
|
if (!store.node[relativePath]) await fetch(path)
|
||||||
setStore("opened", (x) => {
|
setStore("opened", (x) => {
|
||||||
if (x.includes(relative)) return x
|
if (x.includes(relativePath)) return x
|
||||||
return [
|
return [
|
||||||
...opened()
|
...opened()
|
||||||
.filter((x) => x.pinned)
|
.filter((x) => x.pinned)
|
||||||
.map((x) => x.path),
|
.map((x) => x.path),
|
||||||
relative,
|
relativePath,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
setStore("active", relative)
|
setStore("active", relativePath)
|
||||||
if (options?.pinned) setStore("node", path, "pinned", true)
|
if (options?.pinned) setStore("node", path, "pinned", true)
|
||||||
if (options?.view && store.node[relative].view === undefined) setStore("node", path, "view", options.view)
|
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
||||||
if (store.node[relative].loaded) return
|
if (store.node[relativePath].loaded) return
|
||||||
return load(relative)
|
return load(relativePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = async (path: string) => {
|
const list = async (path: string) => {
|
||||||
@@ -212,10 +233,9 @@ function init() {
|
|||||||
if (part.type === "tool" && part.state.status === "completed") {
|
if (part.type === "tool" && part.state.status === "completed") {
|
||||||
switch (part.tool) {
|
switch (part.tool) {
|
||||||
case "read":
|
case "read":
|
||||||
console.log("read", part.state.input)
|
|
||||||
break
|
break
|
||||||
case "edit":
|
case "edit":
|
||||||
load(part.state.input["filePath"] as string)
|
// load(part.state.input["filePath"] as string)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
@@ -223,8 +243,10 @@ function init() {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
case "file.watcher.updated":
|
case "file.watcher.updated":
|
||||||
load(event.properties.file)
|
setTimeout(sync.load.changes, 1000)
|
||||||
sync.load.changes()
|
const relativePath = relative(event.properties.file)
|
||||||
|
if (relativePath.startsWith(".git/")) return
|
||||||
|
load(relativePath)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -298,9 +320,8 @@ function init() {
|
|||||||
setChangeIndex(path: string, index: number | undefined) {
|
setChangeIndex(path: string, index: number | undefined) {
|
||||||
setStore("node", path, "selectedChange", index)
|
setStore("node", path, "selectedChange", index)
|
||||||
},
|
},
|
||||||
changed,
|
|
||||||
changes,
|
changes,
|
||||||
status,
|
changed,
|
||||||
children(path: string) {
|
children(path: string) {
|
||||||
return Object.values(store.node).filter(
|
return Object.values(store.node).filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
@@ -310,6 +331,7 @@ function init() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
search,
|
search,
|
||||||
|
relative,
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode } from "@opencode-ai/sdk"
|
import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode } from "@opencode-ai/sdk"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { createContext, Show, useContext, type ParentProps } from "solid-js"
|
import { createContext, createMemo, Show, useContext, type ParentProps } from "solid-js"
|
||||||
import { useSDK, useEvent } from "@/context"
|
import { useSDK, useEvent } from "@/context"
|
||||||
import { Binary } from "@/utils/binary"
|
import { Binary } from "@/utils/binary"
|
||||||
|
|
||||||
@@ -113,6 +113,9 @@ function init() {
|
|||||||
|
|
||||||
Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
|
Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
|
||||||
|
|
||||||
|
const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
|
||||||
|
const sanitize = (text: string) => text.replace(sanitizer(), "")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: store,
|
data: store,
|
||||||
set: setStore,
|
set: setStore,
|
||||||
@@ -143,6 +146,7 @@ function init() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
load,
|
load,
|
||||||
|
sanitize,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ export default function Page() {
|
|||||||
return (
|
return (
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div
|
<div
|
||||||
class="fixed top-0 left-0 h-full border-r border-border-subtle/30 flex flex-col overflow-hidden"
|
class="fixed top-0 left-0 h-full border-r border-border-subtle/30 flex flex-col overflow-hidden bg-background z-10"
|
||||||
style={`width: ${local.layout.leftWidth()}px`}
|
style={`width: ${local.layout.leftWidth()}px`}
|
||||||
>
|
>
|
||||||
<Tabs class="relative flex flex-col h-full" defaultValue="files">
|
<Tabs class="relative flex flex-col h-full" defaultValue="files">
|
||||||
@@ -261,7 +261,7 @@ export default function Page() {
|
|||||||
<Tabs.Content value="changes" class="grow min-h-0 py-2 bg-background">
|
<Tabs.Content value="changes" class="grow min-h-0 py-2 bg-background">
|
||||||
<Show
|
<Show
|
||||||
when={local.file.changes().length}
|
when={local.file.changes().length}
|
||||||
fallback={<div class="px-2 text-xs text-text-muted">No changes yet</div>}
|
fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}
|
||||||
>
|
>
|
||||||
<ul class="">
|
<ul class="">
|
||||||
<For each={local.file.changes()}>
|
<For each={local.file.changes()}>
|
||||||
@@ -299,7 +299,7 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
<Show when={local.layout.rightPane()}>
|
<Show when={local.layout.rightPane()}>
|
||||||
<div
|
<div
|
||||||
class="fixed top-0 right-0 h-full border-l border-border-subtle/30 flex flex-col overflow-hidden"
|
class="fixed top-0 right-0 h-full border-l border-border-subtle/30 flex flex-col overflow-hidden bg-background z-10"
|
||||||
style={`width: ${local.layout.rightWidth()}px`}
|
style={`width: ${local.layout.rightWidth()}px`}
|
||||||
>
|
>
|
||||||
<div class="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
<div class="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
||||||
@@ -609,24 +609,21 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TabVisual = (props: { file: LocalFile }) => {
|
const TabVisual = (props: { file: LocalFile }) => {
|
||||||
const local = useLocal()
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-x-1.5">
|
<div class="flex items-center gap-x-1.5">
|
||||||
<FileIcon node={props.file} class="" />
|
<FileIcon node={props.file} class="" />
|
||||||
<span
|
<span classList={{ "text-xs": true, "text-primary": !!props.file.status?.status, italic: !props.file.pinned }}>
|
||||||
classList={{ "text-xs": true, "text-primary": local.file.changed(props.file.path), italic: !props.file.pinned }}
|
|
||||||
>
|
|
||||||
{props.file.name}
|
{props.file.name}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs opacity-70">
|
<span class="text-xs opacity-70">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={local.file.status(props.file.path)?.status === "modified"}>
|
<Match when={props.file.status?.status === "modified"}>
|
||||||
<span class="text-primary">M</span>
|
<span class="text-primary">M</span>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={local.file.status(props.file.path)?.status === "added"}>
|
<Match when={props.file.status?.status === "added"}>
|
||||||
<span class="text-success">A</span>
|
<span class="text-success">A</span>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={local.file.status(props.file.path)?.status === "deleted"}>
|
<Match when={props.file.status?.status === "deleted"}>
|
||||||
<span class="text-error">D</span>
|
<span class="text-error">D</span>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ const icons = {
|
|||||||
columns: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4.75 6.75C4.75 5.64543 5.64543 4.75 6.75 4.75H17.25C18.3546 4.75 19.25 5.64543 19.25 6.75V17.25C19.25 18.3546 18.3546 19.25 17.25 19.25H6.75C5.64543 19.25 4.75 18.3546 4.75 17.25V6.75Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 5V19"></path>',
|
columns: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4.75 6.75C4.75 5.64543 5.64543 4.75 6.75 4.75H17.25C18.3546 4.75 19.25 5.64543 19.25 6.75V17.25C19.25 18.3546 18.3546 19.25 17.25 19.25H6.75C5.64543 19.25 4.75 18.3546 4.75 17.25V6.75Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 5V19"></path>',
|
||||||
"open-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.25 4.75v14.5m-3-8.5L9.75 12l1.5 1.25m-4.5 6h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
|
"open-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.25 4.75v14.5m-3-8.5L9.75 12l1.5 1.25m-4.5 6h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
|
||||||
"close-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 4.75v14.5m3-8.5L14.25 12l-1.5 1.25M6.75 19.25h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
|
"close-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 4.75v14.5m3-8.5L14.25 12l-1.5 1.25M6.75 19.25h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
|
||||||
|
"file-search": '<path fill="currentColor" d="M17.25 9.25V10a.75.75 0 0 0 .53-1.28l-.53.53Zm-4.5-4.5.53-.53a.75.75 0 0 0-.53-.22v.75ZM10.25 20a.75.75 0 0 0 0-1.5V20Zm7.427-3.383a.75.75 0 0 0-1.06 1.06l1.06-1.06Zm1.043 3.163a.75.75 0 1 0 1.06-1.06l-1.06 1.06Zm-.94-11.06-4.5-4.5-1.06 1.06 4.5 4.5 1.06-1.06ZM12.75 4h-6v1.5h6V4ZM4 6.75v10.5h1.5V6.75H4ZM6.75 20h3.5v-1.5h-3.5V20ZM12 4.75v3.5h1.5v-3.5H12ZM13.75 10h3.5V8.5h-3.5V10ZM12 8.25c0 .966.784 1.75 1.75 1.75V8.5a.25.25 0 0 1-.25-.25H12Zm-8 9A2.75 2.75 0 0 0 6.75 20v-1.5c-.69 0-1.25-.56-1.25-1.25H4ZM6.75 4A2.75 2.75 0 0 0 4 6.75h1.5c0-.69.56-1.25 1.25-1.25V4Zm8.485 14.47a3.235 3.235 0 0 0 3.236-3.235h-1.5c0 .959-.777 1.736-1.736 1.736v1.5Zm0-4.97c.959 0 1.736.777 1.736 1.735h1.5A3.235 3.235 0 0 0 15.235 12v1.5Zm0-1.5A3.235 3.235 0 0 0 12 15.235h1.5c0-.958.777-1.735 1.735-1.735V12Zm0 4.97a1.735 1.735 0 0 1-1.735-1.735H12a3.235 3.235 0 0 0 3.235 3.236v-1.5Zm1.382.707 2.103 2.103 1.06-1.06-2.103-2.103-1.06 1.06Z"></path>',
|
||||||
|
"folder-search": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.25 19.25h-3.5a2 2 0 0 1-2-2v-9.5h12.5a2 2 0 0 1 2 2v.5m-5.75-2.5-.931-1.958a2 2 0 0 0-1.756-1.042H6.75a2 2 0 0 0-2 2V11m12.695 6.445 1.805 1.805m-3.75-1a2.75 2.75 0 1 0 0-5.5 2.75 2.75 0 0 0 0 5.5Z"></path>',
|
||||||
|
search: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 19.25L15.5 15.5M4.75 11C4.75 7.54822 7.54822 4.75 11 4.75C14.4518 4.75 17.25 7.54822 17.25 11C17.25 14.4518 14.4518 17.25 11 17.25C7.54822 17.25 4.75 14.4518 4.75 11Z"></path>',
|
||||||
|
"web-search": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 8.25v-.5a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v.5m14.5 0H4.75m14.5 0v2m-14.5-2v8a2 2 0 0 0 2 2h2.5m7.743-1.257 2.257 2.257m-4.015-1.53a2.485 2.485 0 1 0 0-4.97 2.485 2.485 0 0 0 0 4.97Z"></path>',
|
||||||
|
loading: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.75v1.5m5.126.624L16 8m3.25 4h-1.5m-.624 5.126-1.768-1.768M12 16.75v2.5m-3.36-3.891-1.768 1.768M7.25 12h-2.5m3.891-3.358L6.874 6.874"></path>',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export function Icon(props: IconProps) {
|
export function Icon(props: IconProps) {
|
||||||
|
|||||||
Reference in New Issue
Block a user