diff options
| author | Adam <[email protected]> | 2026-01-05 00:25:44 -0600 |
|---|---|---|
| committer | adamelmore <[email protected]> | 2026-01-26 11:07:51 -0600 |
| commit | d9eed4c6cacf59089e6b6d6101deff58c7bd5040 (patch) | |
| tree | 9baa833645787d9b9d66cfcbe8363962a85aa6b5 /packages/app/src/context | |
| parent | 7e34d27b77b3ce51cda27110b4ad1d3d5bc4317c (diff) | |
| download | opencode-d9eed4c6cacf59089e6b6d6101deff58c7bd5040.tar.gz opencode-d9eed4c6cacf59089e6b6d6101deff58c7bd5040.zip | |
feat(app): file tree
Diffstat (limited to 'packages/app/src/context')
| -rw-r--r-- | packages/app/src/context/file.tsx | 183 | ||||
| -rw-r--r-- | packages/app/src/context/layout.tsx | 36 |
2 files changed, 216 insertions, 3 deletions
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) { |
