wip(app): file tree mode

This commit is contained in:
adamelmore
2026-01-26 09:39:25 -06:00
parent ebeed03115
commit 801eb5d2cb
4 changed files with 165 additions and 40 deletions

View File

@@ -9,6 +9,7 @@ import {
Match, Match,
splitProps, splitProps,
Switch, Switch,
untrack,
type ComponentProps, type ComponentProps,
type ParentProps, type ParentProps,
} from "solid-js" } from "solid-js"
@@ -21,10 +22,14 @@ export default function FileTree(props: {
nodeClass?: string nodeClass?: string
level?: number level?: number
allowed?: readonly string[] allowed?: readonly string[]
draggable?: boolean
tooltip?: boolean
onFileClick?: (file: FileNode) => void onFileClick?: (file: FileNode) => void
}) { }) {
const file = useFile() const file = useFile()
const level = props.level ?? 0 const level = props.level ?? 0
const draggable = () => props.draggable ?? true
const tooltip = () => props.tooltip ?? true
const filter = createMemo(() => { const filter = createMemo(() => {
const allowed = props.allowed const allowed = props.allowed
@@ -45,6 +50,18 @@ export default function FileTree(props: {
return { files, dirs } return { files, dirs }
}) })
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(() => { createEffect(() => {
void file.tree.list(props.path) void file.tree.list(props.path)
}) })
@@ -78,8 +95,9 @@ export default function FileTree(props: {
[props.nodeClass ?? ""]: !!props.nodeClass, [props.nodeClass ?? ""]: !!props.nodeClass,
}} }}
style={`padding-left: ${8 + level * 12}px`} style={`padding-left: ${8 + level * 12}px`}
draggable={true} draggable={draggable()}
onDragStart={(e: DragEvent) => { onDragStart={(e: DragEvent) => {
if (!draggable()) return
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`) e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`) e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`)
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy" if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
@@ -123,41 +141,54 @@ export default function FileTree(props: {
<For each={nodes()}> <For each={nodes()}>
{(node) => { {(node) => {
const expanded = () => file.tree.state(node.path)?.expanded ?? false 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 ( return (
<Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right"> <Switch>
<Switch> <Match when={node.type === "directory"}>
<Match when={node.type === "directory"}> <Collapsible
<Collapsible variant="ghost"
variant="ghost" class="w-full"
class="w-full" forceMount={false}
forceMount={false} open={expanded()}
open={expanded()} onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} >
> <Collapsible.Trigger>
<Collapsible.Trigger> <Wrapper>
<Node node={node}> <Node node={node}>
<Collapsible.Arrow class="text-icon-weak ml-1" /> <Collapsible.Arrow class="text-icon-weak ml-1" />
<FileIcon node={node} expanded={expanded()} class="text-icon-weak -ml-1 size-4" /> <FileIcon node={node} expanded={expanded()} class="text-icon-weak -ml-1 size-4" />
</Node> </Node>
</Collapsible.Trigger> </Wrapper>
<Collapsible.Content> </Collapsible.Trigger>
<FileTree <Collapsible.Content>
path={node.path} <FileTree
level={level + 1} path={node.path}
allowed={props.allowed} level={level + 1}
onFileClick={props.onFileClick} allowed={props.allowed}
/> draggable={props.draggable}
</Collapsible.Content> tooltip={props.tooltip}
</Collapsible> onFileClick={props.onFileClick}
</Match> />
<Match when={node.type === "file"}> </Collapsible.Content>
</Collapsible>
</Match>
<Match when={node.type === "file"}>
<Wrapper>
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}> <Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
<div class="w-4 shrink-0" /> <div class="w-4 shrink-0" />
<FileIcon node={node} class="text-icon-weak size-4" /> <FileIcon node={node} class="text-icon-weak size-4" />
</Node> </Node>
</Match> </Wrapper>
</Switch> </Match>
</Tooltip> </Switch>
) )
}} }}
</For> </For>

View File

@@ -1037,7 +1037,7 @@ export default function Page() {
return `session-review-diff-${sum}` return `session-review-diff-${sum}`
} }
const scrollToReviewDiff = (path: string, behavior: ScrollBehavior) => { const reviewDiffTop = (path: string) => {
const root = reviewScroll() const root = reviewScroll()
if (!root) return if (!root) return
@@ -1050,15 +1050,25 @@ export default function Page() {
const a = el.getBoundingClientRect() const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect() const b = root.getBoundingClientRect()
const top = a.top - b.top + root.scrollTop return a.top - b.top + root.scrollTop
root.scrollTo({ top, behavior }) }
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 focusReviewDiff = (path: string) => {
const current = view().review.open() ?? [] const current = view().review.open() ?? []
if (!current.includes(path)) view().review.setOpen([...current, path]) if (!current.includes(path)) view().review.setOpen([...current, path])
setPendingDiff(path) setPendingDiff(path)
requestAnimationFrame(() => scrollToReviewDiff(path, "smooth"))
} }
createEffect(() => { createEffect(() => {
@@ -1067,10 +1077,39 @@ export default function Page() {
if (!reviewScroll()) return if (!reviewScroll()) return
if (!diffsReady()) return if (!diffsReady()) return
requestAnimationFrame(() => { const attempt = (count: number) => {
scrollToReviewDiff(pending, "smooth") if (pendingDiff() !== pending) return
setPendingDiff(undefined) 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(() => { const activeTab = createMemo(() => {
@@ -2605,12 +2644,12 @@ export default function Page() {
<Show when={layout.fileTree.opened()}> <Show when={layout.fileTree.opened()}>
<div class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}> <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"> <div class="h-full border-l border-border-weak-base flex flex-col overflow-hidden">
<Tabs value={fileTreeTab()} onChange={setFileTreeTabValue} class="h-full"> <Tabs variant="pill" value={fileTreeTab()} onChange={setFileTreeTabValue} class="h-full">
<Tabs.List class="h-auto"> <Tabs.List>
<Tabs.Trigger value="changes" class="w-1/2" classes={{ button: "w-full" }}> <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
Changes Changes
</Tabs.Trigger> </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 All files
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
@@ -2624,6 +2663,8 @@ export default function Page() {
<FileTree <FileTree
path="" path=""
allowed={diffs().map((d) => d.file)} allowed={diffs().map((d) => d.file)}
draggable={false}
tooltip={false}
onFileClick={(node) => focusReviewDiff(node.path)} onFileClick={(node) => focusReviewDiff(node.path)}
/> />
</Show> </Show>

View File

@@ -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"] { &[data-orientation="vertical"] {
flex-direction: row; flex-direction: row;

View File

@@ -3,7 +3,7 @@ import { Show, splitProps, type JSX } from "solid-js"
import type { ComponentProps, ParentProps, Component } from "solid-js" import type { ComponentProps, ParentProps, Component } from "solid-js"
export interface TabsProps extends ComponentProps<typeof Kobalte> { export interface TabsProps extends ComponentProps<typeof Kobalte> {
variant?: "normal" | "alt" | "settings" variant?: "normal" | "alt" | "pill" | "settings"
orientation?: "horizontal" | "vertical" orientation?: "horizontal" | "vertical"
} }
export interface TabsListProps extends ComponentProps<typeof Kobalte.List> {} export interface TabsListProps extends ComponentProps<typeof Kobalte.List> {}