summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authoradamelmore <[email protected]>2026-01-26 09:39:25 -0600
committeradamelmore <[email protected]>2026-01-26 11:07:52 -0600
commit801eb5d2cb868d0a14c056439e3898b110f4cc21 (patch)
treecf26ac655b9fef7e05e75982ee2aa51c13a5df4b
parentebeed03115b61f812c56c66785a09be61be35468 (diff)
downloadopencode-801eb5d2cb868d0a14c056439e3898b110f4cc21.tar.gz
opencode-801eb5d2cb868d0a14c056439e3898b110f4cc21.zip
wip(app): file tree mode
-rw-r--r--packages/app/src/components/file-tree.tsx85
-rw-r--r--packages/app/src/pages/session.tsx65
-rw-r--r--packages/ui/src/components/tabs.css53
-rw-r--r--packages/ui/src/components/tabs.tsx2
4 files changed, 165 insertions, 40 deletions
diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx
index ac435095d..a48f0039f 100644
--- a/packages/app/src/components/file-tree.tsx
+++ b/packages/app/src/components/file-tree.tsx
@@ -9,6 +9,7 @@ import {
Match,
splitProps,
Switch,
+ untrack,
type ComponentProps,
type ParentProps,
} from "solid-js"
@@ -21,10 +22,14 @@ export default function FileTree(props: {
nodeClass?: string
level?: number
allowed?: readonly string[]
+ draggable?: boolean
+ tooltip?: boolean
onFileClick?: (file: FileNode) => void
}) {
const file = useFile()
const level = props.level ?? 0
+ const draggable = () => props.draggable ?? true
+ const tooltip = () => props.tooltip ?? true
const filter = createMemo(() => {
const allowed = props.allowed
@@ -46,6 +51,18 @@ export default function FileTree(props: {
})
createEffect(() => {
+ const current = filter()
+ if (!current) return
+ if (level !== 0) return
+
+ for (const dir of current.dirs) {
+ const expanded = untrack(() => file.tree.state(dir)?.expanded) ?? false
+ if (expanded) continue
+ file.tree.expand(dir)
+ }
+ })
+
+ createEffect(() => {
void file.tree.list(props.path)
})
@@ -78,8 +95,9 @@ export default function FileTree(props: {
[props.nodeClass ?? ""]: !!props.nodeClass,
}}
style={`padding-left: ${8 + level * 12}px`}
- draggable={true}
+ draggable={draggable()}
onDragStart={(e: DragEvent) => {
+ if (!draggable()) return
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"
@@ -123,41 +141,54 @@ export default function FileTree(props: {
<For each={nodes()}>
{(node) => {
const expanded = () => file.tree.state(node.path)?.expanded ?? false
+ const Wrapper = (p: ParentProps) => {
+ if (!tooltip()) return p.children
+ return (
+ <Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right" class="w-full">
+ {p.children}
+ </Tooltip>
+ )
+ }
+
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>
+ <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>
+ <Wrapper>
<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}
- allowed={props.allowed}
- onFileClick={props.onFileClick}
- />
- </Collapsible.Content>
- </Collapsible>
- </Match>
- <Match when={node.type === "file"}>
+ </Wrapper>
+ </Collapsible.Trigger>
+ <Collapsible.Content>
+ <FileTree
+ path={node.path}
+ level={level + 1}
+ allowed={props.allowed}
+ draggable={props.draggable}
+ tooltip={props.tooltip}
+ onFileClick={props.onFileClick}
+ />
+ </Collapsible.Content>
+ </Collapsible>
+ </Match>
+ <Match when={node.type === "file"}>
+ <Wrapper>
<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>
+ </Wrapper>
+ </Match>
+ </Switch>
)
}}
</For>
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 5160c5288..5ce03f403 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1037,7 +1037,7 @@ export default function Page() {
return `session-review-diff-${sum}`
}
- const scrollToReviewDiff = (path: string, behavior: ScrollBehavior) => {
+ const reviewDiffTop = (path: string) => {
const root = reviewScroll()
if (!root) return
@@ -1050,15 +1050,25 @@ export default function Page() {
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
- const top = a.top - b.top + root.scrollTop
- root.scrollTo({ top, behavior })
+ return a.top - b.top + root.scrollTop
+ }
+
+ const scrollToReviewDiff = (path: string) => {
+ const root = reviewScroll()
+ if (!root) return false
+
+ const top = reviewDiffTop(path)
+ if (top === undefined) return false
+
+ view().setScroll("review", { x: root.scrollLeft, y: top })
+ root.scrollTo({ top, behavior: "auto" })
+ return true
}
const focusReviewDiff = (path: string) => {
const current = view().review.open() ?? []
if (!current.includes(path)) view().review.setOpen([...current, path])
setPendingDiff(path)
- requestAnimationFrame(() => scrollToReviewDiff(path, "smooth"))
}
createEffect(() => {
@@ -1067,10 +1077,39 @@ export default function Page() {
if (!reviewScroll()) return
if (!diffsReady()) return
- requestAnimationFrame(() => {
- scrollToReviewDiff(pending, "smooth")
- setPendingDiff(undefined)
- })
+ const attempt = (count: number) => {
+ if (pendingDiff() !== pending) return
+ if (count > 60) {
+ setPendingDiff(undefined)
+ return
+ }
+
+ const root = reviewScroll()
+ if (!root) {
+ requestAnimationFrame(() => attempt(count + 1))
+ return
+ }
+
+ if (!scrollToReviewDiff(pending)) {
+ requestAnimationFrame(() => attempt(count + 1))
+ return
+ }
+
+ const top = reviewDiffTop(pending)
+ if (top === undefined) {
+ requestAnimationFrame(() => attempt(count + 1))
+ return
+ }
+
+ if (Math.abs(root.scrollTop - top) <= 1) {
+ setPendingDiff(undefined)
+ return
+ }
+
+ requestAnimationFrame(() => attempt(count + 1))
+ }
+
+ requestAnimationFrame(() => attempt(0))
})
const activeTab = createMemo(() => {
@@ -2605,12 +2644,12 @@ export default function Page() {
<Show when={layout.fileTree.opened()}>
<div class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
<div class="h-full border-l border-border-weak-base flex flex-col overflow-hidden">
- <Tabs value={fileTreeTab()} onChange={setFileTreeTabValue} class="h-full">
- <Tabs.List class="h-auto">
- <Tabs.Trigger value="changes" class="w-1/2" classes={{ button: "w-full" }}>
+ <Tabs variant="pill" value={fileTreeTab()} onChange={setFileTreeTabValue} class="h-full">
+ <Tabs.List>
+ <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
Changes
</Tabs.Trigger>
- <Tabs.Trigger value="all" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
+ <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
All files
</Tabs.Trigger>
</Tabs.List>
@@ -2624,6 +2663,8 @@ export default function Page() {
<FileTree
path=""
allowed={diffs().map((d) => d.file)}
+ draggable={false}
+ tooltip={false}
onFileClick={(node) => focusReviewDiff(node.path)}
/>
</Show>
diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css
index cebbd3b4f..2f3c914e1 100644
--- a/packages/ui/src/components/tabs.css
+++ b/packages/ui/src/components/tabs.css
@@ -212,6 +212,59 @@
/* } */
}
+ &[data-variant="pill"][data-orientation="horizontal"] {
+ background-color: transparent;
+
+ [data-slot="tabs-list"] {
+ height: auto;
+ padding: 6px;
+ gap: 4px;
+ border-bottom: 1px solid var(--border-weak-base);
+ background-color: var(--background-base);
+
+ &::after {
+ display: none;
+ }
+ }
+
+ [data-slot="tabs-trigger-wrapper"] {
+ height: 32px;
+ border: none;
+ border-radius: 999px;
+ background-color: transparent;
+ gap: 0;
+
+ /* text-13-medium */
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-style: normal;
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large);
+ letter-spacing: var(--letter-spacing-normal);
+
+ [data-slot="tabs-trigger"] {
+ height: 100%;
+ width: 100%;
+ padding: 0 12px;
+ background-color: transparent;
+ }
+
+ &:hover:not(:disabled) {
+ background-color: var(--surface-raised-base-hover);
+ color: var(--text-strong);
+ }
+
+ &:has([data-selected]) {
+ background-color: var(--surface-raised-base-active);
+ color: var(--text-strong);
+
+ &:hover:not(:disabled) {
+ background-color: var(--surface-raised-base-active);
+ }
+ }
+ }
+ }
+
&[data-orientation="vertical"] {
flex-direction: row;
diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx
index 825bfa859..ddd22ec51 100644
--- a/packages/ui/src/components/tabs.tsx
+++ b/packages/ui/src/components/tabs.tsx
@@ -3,7 +3,7 @@ import { Show, splitProps, type JSX } from "solid-js"
import type { ComponentProps, ParentProps, Component } from "solid-js"
export interface TabsProps extends ComponentProps<typeof Kobalte> {
- variant?: "normal" | "alt" | "settings"
+ variant?: "normal" | "alt" | "pill" | "settings"
orientation?: "horizontal" | "vertical"
}
export interface TabsListProps extends ComponentProps<typeof Kobalte.List> {}