wip(app): file tree mode
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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> {}
|
||||||
|
|||||||
Reference in New Issue
Block a user