chore: refactor packages/app files (#13236)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly>
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
Switch,
|
||||
untrack,
|
||||
type ComponentProps,
|
||||
type JSXElement,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
@@ -59,6 +60,189 @@ export function dirsToExpand(input: {
|
||||
return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
|
||||
}
|
||||
|
||||
const kindLabel = (kind: Kind) => {
|
||||
if (kind === "add") return "A"
|
||||
if (kind === "del") return "D"
|
||||
return "M"
|
||||
}
|
||||
|
||||
const kindTextColor = (kind: Kind) => {
|
||||
if (kind === "add") return "color: var(--icon-diff-add-base)"
|
||||
if (kind === "del") return "color: var(--icon-diff-delete-base)"
|
||||
return "color: var(--icon-warning-active)"
|
||||
}
|
||||
|
||||
const kindDotColor = (kind: Kind) => {
|
||||
if (kind === "add") return "background-color: var(--icon-diff-add-base)"
|
||||
if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
|
||||
return "background-color: var(--icon-warning-active)"
|
||||
}
|
||||
|
||||
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
||||
const kind = kinds?.get(node.path)
|
||||
if (!kind) return
|
||||
if (!marks?.has(node.path)) return
|
||||
return kind
|
||||
}
|
||||
|
||||
const buildDragImage = (target: HTMLElement) => {
|
||||
const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg")
|
||||
const text = target.querySelector("span")
|
||||
if (!icon || !text) return
|
||||
|
||||
const image = document.createElement("div")
|
||||
image.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"
|
||||
image.style.position = "absolute"
|
||||
image.style.top = "-1000px"
|
||||
image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
|
||||
return image
|
||||
}
|
||||
|
||||
const withFileDragImage = (event: DragEvent) => {
|
||||
const image = buildDragImage(event.currentTarget as HTMLElement)
|
||||
if (!image) return
|
||||
document.body.appendChild(image)
|
||||
event.dataTransfer?.setDragImage(image, 0, 12)
|
||||
setTimeout(() => document.body.removeChild(image), 0)
|
||||
}
|
||||
|
||||
const FileTreeNode = (
|
||||
p: ParentProps &
|
||||
ComponentProps<"div"> &
|
||||
ComponentProps<"button"> & {
|
||||
node: FileNode
|
||||
level: number
|
||||
active?: string
|
||||
nodeClass?: string
|
||||
draggable: boolean
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
marks?: Set<string>
|
||||
as?: "div" | "button"
|
||||
},
|
||||
) => {
|
||||
const [local, rest] = splitProps(p, [
|
||||
"node",
|
||||
"level",
|
||||
"active",
|
||||
"nodeClass",
|
||||
"draggable",
|
||||
"kinds",
|
||||
"marks",
|
||||
"as",
|
||||
"children",
|
||||
"class",
|
||||
"classList",
|
||||
])
|
||||
const kind = () => visibleKind(local.node, local.kinds, local.marks)
|
||||
const active = () => !!kind() && !local.node.ignored
|
||||
const color = () => {
|
||||
const value = kind()
|
||||
if (!value) return
|
||||
return kindTextColor(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dynamic
|
||||
component={local.as ?? "div"}
|
||||
classList={{
|
||||
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
||||
"bg-surface-base-active": local.node.path === local.active,
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
[local.nodeClass ?? ""]: !!local.nodeClass,
|
||||
}}
|
||||
style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
|
||||
draggable={local.draggable}
|
||||
onDragStart={(event: DragEvent) => {
|
||||
if (!local.draggable) return
|
||||
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
||||
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
|
||||
withFileDragImage(event)
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
<span
|
||||
classList={{
|
||||
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
|
||||
"text-text-weaker": local.node.ignored,
|
||||
"text-text-weak": !local.node.ignored && !active(),
|
||||
}}
|
||||
style={active() ? color() : undefined}
|
||||
>
|
||||
{local.node.name}
|
||||
</span>
|
||||
{(() => {
|
||||
const value = kind()
|
||||
if (!value) return null
|
||||
if (local.node.type === "file") {
|
||||
return (
|
||||
<span class="shrink-0 w-4 text-center text-12-medium" style={kindTextColor(value)}>
|
||||
{kindLabel(value)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={kindDotColor(value)} />
|
||||
})()}
|
||||
</Dynamic>
|
||||
)
|
||||
}
|
||||
|
||||
const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => {
|
||||
if (!props.enabled) return props.children
|
||||
|
||||
const parts = props.node.path.split("/")
|
||||
const leaf = parts[parts.length - 1] ?? props.node.path
|
||||
const head = parts.slice(0, -1).join("/")
|
||||
const prefix = head ? `${head}/` : ""
|
||||
const label =
|
||||
props.kind === "add"
|
||||
? "Additions"
|
||||
: props.kind === "del"
|
||||
? "Deletions"
|
||||
: props.kind === "mix"
|
||||
? "Modifications"
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
openDelay={2000}
|
||||
placement="bottom-start"
|
||||
class="w-full"
|
||||
contentStyle={{ "max-width": "480px", width: "fit-content" }}
|
||||
value={
|
||||
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
|
||||
<span
|
||||
class="min-w-0 truncate text-text-invert-base"
|
||||
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
|
||||
<Show when={label}>
|
||||
{(text) => (
|
||||
<>
|
||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||
<span class="shrink-0 text-text-invert-strong">{text()}</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.node.type === "directory" && props.node.ignored}>
|
||||
<>
|
||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||
<span class="shrink-0 text-text-invert-strong">Ignored</span>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FileTree(props: {
|
||||
path: string
|
||||
class?: string
|
||||
@@ -230,178 +414,13 @@ export default function FileTree(props: {
|
||||
return out
|
||||
})
|
||||
|
||||
const Node = (
|
||||
p: ParentProps &
|
||||
ComponentProps<"div"> &
|
||||
ComponentProps<"button"> & {
|
||||
node: FileNode
|
||||
as?: "div" | "button"
|
||||
},
|
||||
) => {
|
||||
const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
|
||||
return (
|
||||
<Dynamic
|
||||
component={local.as ?? "div"}
|
||||
classList={{
|
||||
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
||||
"bg-surface-base-active": local.node.path === props.active,
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
||||
}}
|
||||
style={`padding-left: ${Math.max(0, 8 + level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
|
||||
draggable={draggable()}
|
||||
onDragStart={(e: DragEvent) => {
|
||||
if (!draggable()) return
|
||||
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
||||
e.dataTransfer?.setData("text/uri-list", pathToFileUrl(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}
|
||||
>
|
||||
{local.children}
|
||||
{(() => {
|
||||
const kind = kinds()?.get(local.node.path)
|
||||
const marked = marks()?.has(local.node.path) ?? false
|
||||
const active = !!kind && marked && !local.node.ignored
|
||||
const color =
|
||||
kind === "add"
|
||||
? "color: var(--icon-diff-add-base)"
|
||||
: kind === "del"
|
||||
? "color: var(--icon-diff-delete-base)"
|
||||
: kind === "mix"
|
||||
? "color: var(--icon-warning-active)"
|
||||
: undefined
|
||||
return (
|
||||
<span
|
||||
classList={{
|
||||
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
|
||||
"text-text-weaker": local.node.ignored,
|
||||
"text-text-weak": !local.node.ignored && !active,
|
||||
}}
|
||||
style={active ? color : undefined}
|
||||
>
|
||||
{local.node.name}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
{(() => {
|
||||
const kind = kinds()?.get(local.node.path)
|
||||
if (!kind) return null
|
||||
if (!marks()?.has(local.node.path)) return null
|
||||
|
||||
if (local.node.type === "file") {
|
||||
const text = kind === "add" ? "A" : kind === "del" ? "D" : "M"
|
||||
const color =
|
||||
kind === "add"
|
||||
? "color: var(--icon-diff-add-base)"
|
||||
: kind === "del"
|
||||
? "color: var(--icon-diff-delete-base)"
|
||||
: "color: var(--icon-warning-active)"
|
||||
|
||||
return (
|
||||
<span class="shrink-0 w-4 text-center text-12-medium" style={color}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (local.node.type === "directory") {
|
||||
const color =
|
||||
kind === "add"
|
||||
? "background-color: var(--icon-diff-add-base)"
|
||||
: kind === "del"
|
||||
? "background-color: var(--icon-diff-delete-base)"
|
||||
: "background-color: var(--icon-warning-active)"
|
||||
|
||||
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={color} />
|
||||
}
|
||||
|
||||
return null
|
||||
})()}
|
||||
</Dynamic>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
|
||||
<For each={nodes()}>
|
||||
{(node) => {
|
||||
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
||||
const deep = () => deeps().get(node.path) ?? -1
|
||||
const Wrapper = (p: ParentProps) => {
|
||||
if (!tooltip()) return p.children
|
||||
|
||||
const parts = node.path.split("/")
|
||||
const leaf = parts[parts.length - 1] ?? node.path
|
||||
const head = parts.slice(0, -1).join("/")
|
||||
const prefix = head ? `${head}/` : ""
|
||||
|
||||
const kind = () => kinds()?.get(node.path)
|
||||
const label = () => {
|
||||
const k = kind()
|
||||
if (!k) return
|
||||
if (k === "add") return "Additions"
|
||||
if (k === "del") return "Deletions"
|
||||
return "Modifications"
|
||||
}
|
||||
|
||||
const ignored = () => node.type === "directory" && node.ignored
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
openDelay={2000}
|
||||
placement="bottom-start"
|
||||
class="w-full"
|
||||
contentStyle={{ "max-width": "480px", width: "fit-content" }}
|
||||
value={
|
||||
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
|
||||
<span
|
||||
class="min-w-0 truncate text-text-invert-base"
|
||||
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
|
||||
<Show when={label()}>
|
||||
{(t: () => string) => (
|
||||
<>
|
||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||
<span class="shrink-0 text-text-invert-strong">{t()}</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={ignored()}>
|
||||
<>
|
||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||
<span class="shrink-0 text-text-invert-strong">Ignored</span>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{p.children}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
const kind = () => visibleKind(node, kinds(), marks())
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
@@ -415,13 +434,21 @@ export default function FileTree(props: {
|
||||
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
||||
>
|
||||
<Collapsible.Trigger>
|
||||
<Wrapper>
|
||||
<Node node={node}>
|
||||
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
||||
<FileTreeNode
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
nodeClass={props.nodeClass}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
>
|
||||
<div class="size-4 flex items-center justify-center text-icon-weak">
|
||||
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
|
||||
</div>
|
||||
</Node>
|
||||
</Wrapper>
|
||||
</FileTreeNode>
|
||||
</FileTreeNodeTooltip>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="relative pt-0.5">
|
||||
<div
|
||||
@@ -451,12 +478,23 @@ export default function FileTree(props: {
|
||||
</Collapsible>
|
||||
</Match>
|
||||
<Match when={node.type === "file"}>
|
||||
<Wrapper>
|
||||
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
|
||||
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
||||
<FileTreeNode
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
nodeClass={props.nodeClass}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => props.onFileClick?.(node)}
|
||||
>
|
||||
<div class="w-4 shrink-0" />
|
||||
<FileIcon node={node} class="text-icon-weak size-4" />
|
||||
</Node>
|
||||
</Wrapper>
|
||||
</FileTreeNode>
|
||||
</FileTreeNodeTooltip>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user