diff options
| -rw-r--r-- | packages/app/src/components/file-tree.tsx | 186 | ||||
| -rw-r--r-- | packages/app/src/components/session/session-header.tsx | 26 | ||||
| -rw-r--r-- | packages/app/src/context/file.tsx | 183 | ||||
| -rw-r--r-- | packages/app/src/context/layout.tsx | 36 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 24 |
5 files changed, 363 insertions, 92 deletions
diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 3439d366c..791b33b4a 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -1,111 +1,121 @@ -import { useLocal, type LocalFile } from "@/context/local" +import { useFile } from "@/context/file" import { Collapsible } from "@opencode-ai/ui/collapsible" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { For, Match, Switch, type ComponentProps, type ParentProps } from "solid-js" +import { createEffect, For, Match, splitProps, Switch, type ComponentProps, type ParentProps } from "solid-js" import { Dynamic } from "solid-js/web" +import type { FileNode } from "@opencode-ai/sdk/v2" export default function FileTree(props: { path: string class?: string nodeClass?: string level?: number - onFileClick?: (file: LocalFile) => void + onFileClick?: (file: FileNode) => void }) { - const local = useLocal() + const file = useFile() const level = props.level ?? 0 - const Node = (p: ParentProps & ComponentProps<"div"> & { node: LocalFile; as?: "div" | "button" }) => ( - <Dynamic - component={p.as ?? "div"} - classList={{ - "p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element": true, - // "bg-background-element": local.file.active()?.path === p.node.path, - [props.nodeClass ?? ""]: !!props.nodeClass, - }} - style={`padding-left: ${level * 10}px`} - draggable={true} - onDragStart={(e: any) => { - const evt = e as globalThis.DragEvent - evt.dataTransfer!.effectAllowed = "copy" - evt.dataTransfer!.setData("text/plain", `file:${p.node.path}`) + createEffect(() => { + void file.tree.list(props.path) + }) - // Create custom drag image without margins - const dragImage = document.createElement("div") - dragImage.className = - "flex items-center gap-x-2 px-2 py-1 bg-background-element rounded-md border border-border-1" - dragImage.style.position = "absolute" - dragImage.style.top = "-1000px" + const Node = ( + p: ParentProps & + ComponentProps<"div"> & + ComponentProps<"button"> & { + node: FileNode + as?: "div" | "button" + }, + ) => { + const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"]) + return ( + <Dynamic + component={local.as ?? "div"} + classList={{ + "w-full flex items-center gap-x-2 rounded-md px-2 py-1 hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true, + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + [props.nodeClass ?? ""]: !!props.nodeClass, + }} + style={`padding-left: ${8 + level * 12}px`} + draggable={true} + onDragStart={(e: DragEvent) => { + e.dataTransfer?.setData("text/plain", `file:${local.node.path}`) + e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`) + if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy" - // Copy only the icon and text content without padding - const icon = e.currentTarget.querySelector("svg") - const text = e.currentTarget.querySelector("span") - if (icon && text) { - dragImage.innerHTML = icon.outerHTML + text.outerHTML - } + const dragImage = document.createElement("div") + dragImage.className = + "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong" + dragImage.style.position = "absolute" + dragImage.style.top = "-1000px" - document.body.appendChild(dragImage) - evt.dataTransfer!.setDragImage(dragImage, 0, 12) - setTimeout(() => document.body.removeChild(dragImage), 0) - }} - {...p} - > - {p.children} - <span - classList={{ - "text-xs whitespace-nowrap truncate": true, - "text-text-muted/40": p.node.ignored, - "text-text-muted/80": !p.node.ignored, - // "!text-text": local.file.active()?.path === p.node.path, - // "!text-primary": local.file.changed(p.node.path), + const icon = + (e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ?? + (e.currentTarget as HTMLElement).querySelector("svg") + const text = (e.currentTarget as HTMLElement).querySelector("span") + if (icon && text) { + dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML + } + + document.body.appendChild(dragImage) + e.dataTransfer?.setDragImage(dragImage, 0, 12) + setTimeout(() => document.body.removeChild(dragImage), 0) }} + {...rest} > - {p.node.name} - </span> - {/* <Show when={local.file.changed(p.node.path)}> */} - {/* <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" /> */} - {/* </Show> */} - </Dynamic> - ) + {local.children} + <span + classList={{ + "text-12-regular whitespace-nowrap truncate": true, + "text-text-weaker": local.node.ignored, + "text-text-weak": !local.node.ignored, + }} + > + {local.node.name} + </span> + </Dynamic> + ) + } return ( - <div class={`flex flex-col ${props.class}`}> - <For each={local.file.children(props.path)}> - {(node) => ( - <Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right"> - <Switch> - <Match when={node.type === "directory"}> - <Collapsible - variant="ghost" - class="w-full" - forceMount={false} - // open={local.file.node(node.path)?.expanded} - onOpenChange={(open) => (open ? local.file.expand(node.path) : local.file.collapse(node.path))} - > - <Collapsible.Trigger> - <Node node={node}> - <Collapsible.Arrow class="text-text-muted/60 ml-1" /> - <FileIcon - node={node} - // expanded={local.file.node(node.path).expanded} - class="text-text-muted/60 -ml-1" - /> - </Node> - </Collapsible.Trigger> - <Collapsible.Content> - <FileTree path={node.path} level={level + 1} onFileClick={props.onFileClick} /> - </Collapsible.Content> - </Collapsible> - </Match> - <Match when={node.type === "file"}> - <Node node={node} as="button" onClick={() => props.onFileClick?.(node)}> - <div class="w-4 shrink-0" /> - <FileIcon node={node} class="text-primary" /> - </Node> - </Match> - </Switch> - </Tooltip> - )} + <div class={`flex flex-col ${props.class ?? ""}`}> + <For each={file.tree.children(props.path)}> + {(node) => { + const expanded = () => file.tree.state(node.path)?.expanded ?? false + return ( + <Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right"> + <Switch> + <Match when={node.type === "directory"}> + <Collapsible + variant="ghost" + class="w-full" + forceMount={false} + open={expanded()} + onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} + > + <Collapsible.Trigger> + <Node node={node}> + <Collapsible.Arrow class="text-icon-weak ml-1" /> + <FileIcon node={node} expanded={expanded()} class="text-icon-weak -ml-1 size-4" /> + </Node> + </Collapsible.Trigger> + <Collapsible.Content> + <FileTree path={node.path} level={level + 1} onFileClick={props.onFileClick} /> + </Collapsible.Content> + </Collapsible> + </Match> + <Match when={node.type === "file"}> + <Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}> + <div class="w-4 shrink-0" /> + <FileIcon node={node} class="text-icon-weak size-4" /> + </Node> + </Match> + </Switch> + </Tooltip> + ) + }} </For> </div> ) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 90daa971d..8480e6060 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -281,6 +281,32 @@ export function SessionHeader() { </TooltipKeybind> </div> <div class="hidden md:block shrink-0"> + <Tooltip value="Toggle file tree" placement="bottom"> + <Button + variant="ghost" + class="group/file-tree-toggle size-5 p-0" + onClick={() => { + const opening = !layout.fileTree.opened() + if (opening && !view().reviewPanel.opened()) view().reviewPanel.open() + layout.fileTree.toggle() + }} + aria-label="Toggle file tree" + aria-expanded={layout.fileTree.opened()} + > + <div class="relative flex items-center justify-center size-4"> + <Icon + size="small" + name="bullet-list" + classList={{ + "text-icon-strong": layout.fileTree.opened(), + "text-icon-weak": !layout.fileTree.opened(), + }} + /> + </div> + </Button> + </Tooltip> + </div> + <div class="hidden md:block shrink-0"> <TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}> <Button variant="ghost" diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index afde451ff..b5673f584 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -1,7 +1,7 @@ import { createEffect, createMemo, createRoot, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" -import type { FileContent } from "@opencode-ai/sdk/v2" +import type { FileContent, FileNode } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" import { useParams } from "@solidjs/router" import { getFilename } from "@opencode-ai/util/path" @@ -39,6 +39,14 @@ export type FileState = { content?: FileContent } +type DirectoryState = { + expanded: boolean + loaded?: boolean + loading?: boolean + error?: string + children?: string[] +} + function stripFileProtocol(input: string) { if (!input.startsWith("file://")) return input return input.slice("file://".length) @@ -285,6 +293,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ } const inflight = new Map<string, Promise<void>>() + const treeInflight = new Map<string, Promise<void>>() const [store, setStore] = createStore<{ file: Record<string, FileState> @@ -292,10 +301,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ file: {}, }) + const [tree, setTree] = createStore<{ + node: Record<string, FileNode> + dir: Record<string, DirectoryState> + }>({ + node: {}, + dir: { "": { expanded: true } }, + }) + createEffect(() => { scope() inflight.clear() + treeInflight.clear() setStore("file", {}) + setTree("node", {}) + setTree("dir", { "": { expanded: true } }) }) const viewCache = new Map<string, ViewCacheEntry>() @@ -407,14 +427,156 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ return promise } + function normalizeDir(input: string) { + return normalize(input).replace(/\/+$/, "") + } + + function ensureDir(path: string) { + if (tree.dir[path]) return + setTree("dir", path, { expanded: false }) + } + + function listDir(input: string, options?: { force?: boolean }) { + const dir = normalizeDir(input) + ensureDir(dir) + + const current = tree.dir[dir] + if (!options?.force && current?.loaded) return Promise.resolve() + + const pending = treeInflight.get(dir) + if (pending) return pending + + setTree( + "dir", + dir, + produce((draft) => { + draft.loading = true + draft.error = undefined + }), + ) + + const directory = scope() + + const promise = sdk.client.file + .list({ path: dir }) + .then((x) => { + if (scope() !== directory) return + const nodes = x.data ?? [] + const prevChildren = tree.dir[dir]?.children ?? [] + const nextChildren = nodes.map((node) => node.path) + const nextSet = new Set(nextChildren) + + setTree( + "node", + produce((draft) => { + const removedDirs: string[] = [] + + for (const child of prevChildren) { + if (nextSet.has(child)) continue + const existing = draft[child] + if (existing?.type === "directory") removedDirs.push(child) + delete draft[child] + } + + if (removedDirs.length > 0) { + const keys = Object.keys(draft) + for (const key of keys) { + for (const removed of removedDirs) { + if (!key.startsWith(removed + "/")) continue + delete draft[key] + break + } + } + } + + for (const node of nodes) { + draft[node.path] = node + } + }), + ) + + setTree( + "dir", + dir, + produce((draft) => { + draft.loaded = true + draft.loading = false + draft.children = nextChildren + }), + ) + }) + .catch((e) => { + if (scope() !== directory) return + setTree( + "dir", + dir, + produce((draft) => { + draft.loading = false + draft.error = e.message + }), + ) + showToast({ + variant: "error", + title: "Failed to list files", + description: e.message, + }) + }) + .finally(() => { + treeInflight.delete(dir) + }) + + treeInflight.set(dir, promise) + return promise + } + + function expandDir(input: string) { + const dir = normalizeDir(input) + ensureDir(dir) + setTree("dir", dir, "expanded", true) + void listDir(dir) + } + + function collapseDir(input: string) { + const dir = normalizeDir(input) + ensureDir(dir) + setTree("dir", dir, "expanded", false) + } + + function dirState(input: string) { + const dir = normalizeDir(input) + return tree.dir[dir] + } + + function children(input: string) { + const dir = normalizeDir(input) + const ids = tree.dir[dir]?.children + if (!ids) return [] + const out: FileNode[] = [] + for (const id of ids) { + const node = tree.node[id] + if (node) out.push(node) + } + return out + } + const stop = sdk.event.listen((e) => { const event = e.details if (event.type !== "file.watcher.updated") return const path = normalize(event.properties.file) if (!path) return if (path.startsWith(".git/")) return - if (!store.file[path]) return - load(path, { force: true }) + + if (store.file[path]) { + load(path, { force: true }) + } + + const kind = event.properties.event + if (kind !== "add" && kind !== "unlink") return + + const parent = path.split("/").slice(0, -1).join("/") + if (!tree.dir[parent]?.loaded) return + + listDir(parent, { force: true }) }) const get = (input: string) => store.file[normalize(input)] @@ -448,6 +610,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ normalize, tab, pathFromTab, + tree: { + list: listDir, + refresh: (input: string) => listDir(input, { force: true }), + state: dirState, + children, + expand: expandDir, + collapse: collapseDir, + toggle(input: string) { + if (dirState(input)?.expanded) { + collapseDir(input) + return + } + expandDir(input) + }, + }, get, load, scrollTop, diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 5bb6f92ec..414c3d6f1 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -82,6 +82,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( diffStyle: "split" as ReviewDiffStyle, panelOpened: true, }, + fileTree: { + opened: false, + width: 260, + }, session: { width: 600, }, @@ -449,6 +453,38 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "diffStyle", diffStyle) }, }, + fileTree: { + opened: createMemo(() => store.fileTree?.opened ?? false), + width: createMemo(() => store.fileTree?.width ?? 260), + open() { + if (!store.fileTree) { + setStore("fileTree", { opened: true, width: 260 }) + return + } + setStore("fileTree", "opened", true) + }, + close() { + if (!store.fileTree) { + setStore("fileTree", { opened: false, width: 260 }) + return + } + setStore("fileTree", "opened", false) + }, + toggle() { + if (!store.fileTree) { + setStore("fileTree", { opened: true, width: 260 }) + return + } + setStore("fileTree", "opened", (x) => !x) + }, + resize(width: number) { + if (!store.fileTree) { + setStore("fileTree", { opened: true, width }) + return + } + setStore("fileTree", "width", width) + }, + }, session: { width: createMemo(() => store.session?.width ?? 600), resize(width: number) { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 5e5cba69c..413d8ac34 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -32,6 +32,7 @@ import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode" import { findLast } from "@opencode-ai/util/array" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" +import FileTree from "@/components/file-tree" import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectMcp } from "@/components/dialog-select-mcp" import { DialogFork } from "@/components/dialog-fork" @@ -1811,8 +1812,29 @@ export default function Page() { <aside id="review-panel" aria-label={language.t("session.panel.reviewAndFiles")} - class="relative flex-1 min-w-0 h-full border-l border-border-weak-base" + class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex" > + <Show when={layout.fileTree.opened()}> + <div class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}> + <div class="h-full bg-background-base border-r border-border-weak-base flex flex-col"> + <div class="hidden h-12 shrink-0 flex items-center px-3 border-b border-border-weak-base text-12-medium text-text-weak"> + Files + </div> + <div class="flex-1 min-h-0 overflow-y-auto no-scrollbar p-2"> + <FileTree path="" onFileClick={(node) => openTab(file.tab(node.path))} /> + </div> + </div> + <ResizeHandle + direction="horizontal" + size={layout.fileTree.width()} + min={200} + max={480} + collapseThreshold={160} + onResize={layout.fileTree.resize} + onCollapse={layout.fileTree.close} + /> + </div> + </Show> <DragDropProvider onDragStart={handleDragStart} onDragEnd={handleDragEnd} |
