feat(app): file tree
This commit is contained in:
@@ -1,111 +1,121 @@
|
|||||||
import { useLocal, type LocalFile } from "@/context/local"
|
import { useFile } from "@/context/file"
|
||||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
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 { Dynamic } from "solid-js/web"
|
||||||
|
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
export default function FileTree(props: {
|
export default function FileTree(props: {
|
||||||
path: string
|
path: string
|
||||||
class?: string
|
class?: string
|
||||||
nodeClass?: string
|
nodeClass?: string
|
||||||
level?: number
|
level?: number
|
||||||
onFileClick?: (file: LocalFile) => void
|
onFileClick?: (file: FileNode) => void
|
||||||
}) {
|
}) {
|
||||||
const local = useLocal()
|
const file = useFile()
|
||||||
const level = props.level ?? 0
|
const level = props.level ?? 0
|
||||||
|
|
||||||
const Node = (p: ParentProps & ComponentProps<"div"> & { node: LocalFile; as?: "div" | "button" }) => (
|
createEffect(() => {
|
||||||
<Dynamic
|
void file.tree.list(props.path)
|
||||||
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}`)
|
|
||||||
|
|
||||||
// Create custom drag image without margins
|
const Node = (
|
||||||
const dragImage = document.createElement("div")
|
p: ParentProps &
|
||||||
dragImage.className =
|
ComponentProps<"div"> &
|
||||||
"flex items-center gap-x-2 px-2 py-1 bg-background-element rounded-md border border-border-1"
|
ComponentProps<"button"> & {
|
||||||
dragImage.style.position = "absolute"
|
node: FileNode
|
||||||
dragImage.style.top = "-1000px"
|
as?: "div" | "button"
|
||||||
|
},
|
||||||
// Copy only the icon and text content without padding
|
) => {
|
||||||
const icon = e.currentTarget.querySelector("svg")
|
const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
|
||||||
const text = e.currentTarget.querySelector("span")
|
return (
|
||||||
if (icon && text) {
|
<Dynamic
|
||||||
dragImage.innerHTML = icon.outerHTML + text.outerHTML
|
component={local.as ?? "div"}
|
||||||
}
|
|
||||||
|
|
||||||
document.body.appendChild(dragImage)
|
|
||||||
evt.dataTransfer!.setDragImage(dragImage, 0, 12)
|
|
||||||
setTimeout(() => document.body.removeChild(dragImage), 0)
|
|
||||||
}}
|
|
||||||
{...p}
|
|
||||||
>
|
|
||||||
{p.children}
|
|
||||||
<span
|
|
||||||
classList={{
|
classList={{
|
||||||
"text-xs whitespace-nowrap truncate": true,
|
"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,
|
||||||
"text-text-muted/40": p.node.ignored,
|
...(local.classList ?? {}),
|
||||||
"text-text-muted/80": !p.node.ignored,
|
[local.class ?? ""]: !!local.class,
|
||||||
// "!text-text": local.file.active()?.path === p.node.path,
|
[props.nodeClass ?? ""]: !!props.nodeClass,
|
||||||
// "!text-primary": local.file.changed(p.node.path),
|
|
||||||
}}
|
}}
|
||||||
|
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"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
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}
|
{local.children}
|
||||||
</span>
|
<span
|
||||||
{/* <Show when={local.file.changed(p.node.path)}> */}
|
classList={{
|
||||||
{/* <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" /> */}
|
"text-12-regular whitespace-nowrap truncate": true,
|
||||||
{/* </Show> */}
|
"text-text-weaker": local.node.ignored,
|
||||||
</Dynamic>
|
"text-text-weak": !local.node.ignored,
|
||||||
)
|
}}
|
||||||
|
>
|
||||||
|
{local.node.name}
|
||||||
|
</span>
|
||||||
|
</Dynamic>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`flex flex-col ${props.class}`}>
|
<div class={`flex flex-col ${props.class ?? ""}`}>
|
||||||
<For each={local.file.children(props.path)}>
|
<For each={file.tree.children(props.path)}>
|
||||||
{(node) => (
|
{(node) => {
|
||||||
<Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right">
|
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
||||||
<Switch>
|
return (
|
||||||
<Match when={node.type === "directory"}>
|
<Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right">
|
||||||
<Collapsible
|
<Switch>
|
||||||
variant="ghost"
|
<Match when={node.type === "directory"}>
|
||||||
class="w-full"
|
<Collapsible
|
||||||
forceMount={false}
|
variant="ghost"
|
||||||
// open={local.file.node(node.path)?.expanded}
|
class="w-full"
|
||||||
onOpenChange={(open) => (open ? local.file.expand(node.path) : local.file.collapse(node.path))}
|
forceMount={false}
|
||||||
>
|
open={expanded()}
|
||||||
<Collapsible.Trigger>
|
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
||||||
<Node node={node}>
|
>
|
||||||
<Collapsible.Arrow class="text-text-muted/60 ml-1" />
|
<Collapsible.Trigger>
|
||||||
<FileIcon
|
<Node node={node}>
|
||||||
node={node}
|
<Collapsible.Arrow class="text-icon-weak ml-1" />
|
||||||
// expanded={local.file.node(node.path).expanded}
|
<FileIcon node={node} expanded={expanded()} class="text-icon-weak -ml-1 size-4" />
|
||||||
class="text-text-muted/60 -ml-1"
|
</Node>
|
||||||
/>
|
</Collapsible.Trigger>
|
||||||
</Node>
|
<Collapsible.Content>
|
||||||
</Collapsible.Trigger>
|
<FileTree path={node.path} level={level + 1} onFileClick={props.onFileClick} />
|
||||||
<Collapsible.Content>
|
</Collapsible.Content>
|
||||||
<FileTree path={node.path} level={level + 1} onFileClick={props.onFileClick} />
|
</Collapsible>
|
||||||
</Collapsible.Content>
|
</Match>
|
||||||
</Collapsible>
|
<Match when={node.type === "file"}>
|
||||||
</Match>
|
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
|
||||||
<Match when={node.type === "file"}>
|
<div class="w-4 shrink-0" />
|
||||||
<Node node={node} as="button" onClick={() => props.onFileClick?.(node)}>
|
<FileIcon node={node} class="text-icon-weak size-4" />
|
||||||
<div class="w-4 shrink-0" />
|
</Node>
|
||||||
<FileIcon node={node} class="text-primary" />
|
</Match>
|
||||||
</Node>
|
</Switch>
|
||||||
</Match>
|
</Tooltip>
|
||||||
</Switch>
|
)
|
||||||
</Tooltip>
|
}}
|
||||||
)}
|
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -280,6 +280,32 @@ export function SessionHeader() {
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</div>
|
</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">
|
<div class="hidden md:block shrink-0">
|
||||||
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
|
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
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 { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
@@ -39,6 +39,14 @@ export type FileState = {
|
|||||||
content?: FileContent
|
content?: FileContent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DirectoryState = {
|
||||||
|
expanded: boolean
|
||||||
|
loaded?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
error?: string
|
||||||
|
children?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
function stripFileProtocol(input: string) {
|
function stripFileProtocol(input: string) {
|
||||||
if (!input.startsWith("file://")) return input
|
if (!input.startsWith("file://")) return input
|
||||||
return input.slice("file://".length)
|
return input.slice("file://".length)
|
||||||
@@ -285,6 +293,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
|
const treeInflight = new Map<string, Promise<void>>()
|
||||||
|
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
file: Record<string, FileState>
|
file: Record<string, FileState>
|
||||||
@@ -292,10 +301,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
file: {},
|
file: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [tree, setTree] = createStore<{
|
||||||
|
node: Record<string, FileNode>
|
||||||
|
dir: Record<string, DirectoryState>
|
||||||
|
}>({
|
||||||
|
node: {},
|
||||||
|
dir: { "": { expanded: true } },
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
scope()
|
scope()
|
||||||
inflight.clear()
|
inflight.clear()
|
||||||
|
treeInflight.clear()
|
||||||
setStore("file", {})
|
setStore("file", {})
|
||||||
|
setTree("node", {})
|
||||||
|
setTree("dir", { "": { expanded: true } })
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewCache = new Map<string, ViewCacheEntry>()
|
const viewCache = new Map<string, ViewCacheEntry>()
|
||||||
@@ -407,14 +427,156 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
return promise
|
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 stop = sdk.event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (event.type !== "file.watcher.updated") return
|
if (event.type !== "file.watcher.updated") return
|
||||||
const path = normalize(event.properties.file)
|
const path = normalize(event.properties.file)
|
||||||
if (!path) return
|
if (!path) return
|
||||||
if (path.startsWith(".git/")) 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)]
|
const get = (input: string) => store.file[normalize(input)]
|
||||||
@@ -448,6 +610,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
normalize,
|
normalize,
|
||||||
tab,
|
tab,
|
||||||
pathFromTab,
|
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,
|
get,
|
||||||
load,
|
load,
|
||||||
scrollTop,
|
scrollTop,
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
diffStyle: "split" as ReviewDiffStyle,
|
diffStyle: "split" as ReviewDiffStyle,
|
||||||
panelOpened: true,
|
panelOpened: true,
|
||||||
},
|
},
|
||||||
|
fileTree: {
|
||||||
|
opened: false,
|
||||||
|
width: 260,
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
width: 600,
|
width: 600,
|
||||||
},
|
},
|
||||||
@@ -449,6 +453,38 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
setStore("review", "diffStyle", diffStyle)
|
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: {
|
session: {
|
||||||
width: createMemo(() => store.session?.width ?? 600),
|
width: createMemo(() => store.session?.width ?? 600),
|
||||||
resize(width: number) {
|
resize(width: number) {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
|
|||||||
import { findLast } from "@opencode-ai/util/array"
|
import { findLast } from "@opencode-ai/util/array"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||||
|
import FileTree from "@/components/file-tree"
|
||||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||||
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
||||||
import { DialogFork } from "@/components/dialog-fork"
|
import { DialogFork } from "@/components/dialog-fork"
|
||||||
@@ -1811,8 +1812,29 @@ export default function Page() {
|
|||||||
<aside
|
<aside
|
||||||
id="review-panel"
|
id="review-panel"
|
||||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
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
|
<DragDropProvider
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
|
|||||||
Reference in New Issue
Block a user