diff --git a/web/src/lib/components/diff/concise-diff-view.svelte.ts b/web/src/lib/components/diff/concise-diff-view.svelte.ts index 0ca12d9..a19de04 100644 --- a/web/src/lib/components/diff/concise-diff-view.svelte.ts +++ b/web/src/lib/components/diff/concise-diff-view.svelte.ts @@ -10,13 +10,13 @@ import { type ThemeRegistration, bundledThemes, } from "shiki"; -import { guessLanguageFromExtension, type MutableValue, type ReadableBoxedValues } from "$lib/util"; +import { guessLanguageFromExtension, type MutableValue } from "$lib/util"; import type { IRawThemeSetting } from "shiki/textmate"; import chroma from "chroma-js"; import { getEffectiveGlobalTheme } from "$lib/theme.svelte"; import { onDestroy } from "svelte"; import { DEFAULT_THEME_LIGHT } from "$lib/global-options.svelte"; -import type { WritableBoxedValues } from "svelte-toolbelt"; +import type { ReadableBoxedValues, WritableBoxedValues } from "svelte-toolbelt"; import type { Attachment } from "svelte/attachments"; import { on } from "svelte/events"; import { watch } from "runed"; diff --git a/web/src/lib/components/tree/index.svelte.ts b/web/src/lib/components/tree/index.svelte.ts index 4bcae4d..cfbb1cd 100644 --- a/web/src/lib/components/tree/index.svelte.ts +++ b/web/src/lib/components/tree/index.svelte.ts @@ -11,6 +11,7 @@ export type TreeNode = { export interface TreeNodeView extends TreeNode { backingNode: TreeNode; visibleChildren: TreeNodeView[]; + depth: number; } export interface TreeProps { @@ -115,18 +116,18 @@ export function collectAllNodes(roots: TreeNode[]): Set> { // Keep any nodes where the filter passes and all their parents export function filteredView(roots: TreeNode[], filter: ((node: TreeNode) => boolean) | null): Set> { if (filter === null) { - function walkDirect(node: TreeNode): TreeNodeView { - return { ...node, backingNode: node, visibleChildren: node.children.map(walkDirect) }; + function walkDirect(node: TreeNode, depth: number): TreeNodeView { + return { ...node, backingNode: node, visibleChildren: node.children.map((child) => walkDirect(child, depth + 1)), depth }; } - return new Set(roots.map((root) => walkDirect(root))); + return new Set(roots.map((root) => walkDirect(root, 0))); } - function walkFiltered(node: TreeNode): TreeNodeView | null { + function walkFiltered(node: TreeNode, depth: number): TreeNodeView | null { let pass = filter!(node); - const nodeView: TreeNodeView = { ...node, backingNode: node, visibleChildren: [] }; + const nodeView: TreeNodeView = { ...node, backingNode: node, visibleChildren: [], depth }; for (const child of node.children) { - const newChild = walkFiltered(child); + const newChild = walkFiltered(child, depth + 1); if (newChild) { pass = true; nodeView.visibleChildren.push(newChild); @@ -139,7 +140,7 @@ export function filteredView(roots: TreeNode[], filter: ((node: TreeNode> = new Set>(); for (const root of roots) { - const rootView = walkFiltered(root); + const rootView = walkFiltered(root, 0); if (rootView) { filtered.add(rootView); } diff --git a/web/src/lib/diff-viewer.svelte.ts b/web/src/lib/diff-viewer.svelte.ts index 60339a0..9801ed8 100644 --- a/web/src/lib/diff-viewer.svelte.ts +++ b/web/src/lib/diff-viewer.svelte.ts @@ -231,7 +231,7 @@ const modifyStatusProps: FileStatusProps = { title: "Modified", }; const renamedStatusProps: FileStatusProps = { - iconClasses: "iconify octicon--file-moved-16 text-gray-600", + iconClasses: "iconify octicon--file-moved-16 text-em-med", title: "Renamed", }; const renamedModifiedStatusProps: FileStatusProps = { diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index 9ee88aa..e09488c 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -3,7 +3,6 @@ import type { FileStatus } from "./github.svelte"; import type { TreeNode } from "$lib/components/tree/index.svelte"; import type { BundledLanguage, SpecialLanguage } from "shiki"; import { onMount } from "svelte"; -import type { ReadableBox } from "svelte-toolbelt"; import { on } from "svelte/events"; import { type Attachment } from "svelte/attachments"; @@ -191,10 +190,15 @@ export function splitMultiFilePatch(patchContent: string): [BasicHeader, string] return patches; } -export type FileTreeNodeData = { - data: FileDetails | string; - type: "file" | "directory"; -}; +export type FileTreeNodeData = + | { + type: "file"; + file: FileDetails; + } + | { + type: "directory"; + name: string; + }; export function makeFileTree(paths: FileDetails[]): TreeNode[] { if (paths.length === 0) { @@ -203,7 +207,7 @@ export function makeFileTree(paths: FileDetails[]): TreeNode[] const root: TreeNode = { children: [], - data: { type: "directory", data: "" }, + data: { type: "directory", name: "" }, }; for (const details of paths) { @@ -211,16 +215,22 @@ export function makeFileTree(paths: FileDetails[]): TreeNode[] let current = root; for (let i = 0; i < parts.length; i++) { const part = parts[i]; - const existingChild = current.children.find((child) => child.data.data === part); + const existingChild = current.children.find((child) => child.data.type === "directory" && child.data.name === part); if (existingChild) { current = existingChild; } else { const file = i === parts.length - 1; - const data: FileDetails | string = file ? details : part; - const type = file ? "file" : "directory"; const newChild: TreeNode = { children: [], - data: { data, type }, + data: file + ? { + type: "file", + file: details, + } + : { + type: "directory", + name: part, + }, }; current.children.push(newChild); current = newChild; @@ -234,10 +244,10 @@ export function makeFileTree(paths: FileDetails[]): TreeNode[] } if (node.children.length === 1 && node.data.type === "directory" && node.children[0].data.type === "directory") { - if (node.data.data !== "") { - node.data.data = `${node.data.data}/${node.children[0].data.data}`; + if (node.data.name !== "") { + node.data.name = `${node.data.name}/${node.children[0].data.name}`; } else { - node.data.data = node.children[0].data.data; + node.data.name = node.children[0].data.name; } node.children = node.children[0].children; } @@ -245,7 +255,7 @@ export function makeFileTree(paths: FileDetails[]): TreeNode[] mergeRedundantDirectories(root); - if (root.data.type === "directory" && root.data.data === "") { + if (root.data.type === "directory" && root.data.name === "") { return root.children; } return [root]; @@ -482,8 +492,3 @@ export function animationFramePromise() { export async function yieldToBrowser() { await new Promise((resolve) => setTimeout(resolve, 0)); } - -// from bits-ui internals -export type ReadableBoxedValues = { - [K in keyof T]: ReadableBox; -}; diff --git a/web/src/routes/DiffSearch.svelte b/web/src/routes/DiffSearch.svelte index bc4326e..0431915 100644 --- a/web/src/routes/DiffSearch.svelte +++ b/web/src/routes/DiffSearch.svelte @@ -61,7 +61,7 @@ autocomplete="off" style="padding-inline-end: {0.5 + controlsWidth / 16}rem;" /> - +
{#if viewer.searchQueryDebounced.current} {#await viewer.searchResults} diff --git a/web/src/routes/FileHeader.svelte b/web/src/routes/FileHeader.svelte index 85192bf..00507a2 100644 --- a/web/src/routes/FileHeader.svelte +++ b/web/src/routes/FileHeader.svelte @@ -25,7 +25,7 @@ const fileTreeElement = document.getElementById("file-tree-file-" + index); if (fileTreeElement) { popoverOpen = false; - viewer.tree?.expandParents((node) => node.data === value); + viewer.tree?.expandParents((node) => node.type === "file" && node.file === value); requestAnimationFrame(() => { fileTreeElement.focus(); }); diff --git a/web/src/routes/Sidebar.svelte b/web/src/routes/Sidebar.svelte index be000fa..f751409 100644 --- a/web/src/routes/Sidebar.svelte +++ b/web/src/routes/Sidebar.svelte @@ -4,38 +4,34 @@ import Tree from "$lib/components/tree/Tree.svelte"; import { type TreeNode } from "$lib/components/tree/index.svelte"; import { on } from "svelte/events"; - import { type Attachment } from "svelte/attachments"; + import { createAttachmentKey, type Attachment } from "svelte/attachments"; import { boolAttr } from "runed"; const viewer = MultiFileDiffViewerState.get(); function filterFileNode(file: TreeNode): boolean { - return file.data.type === "file" && viewer.filterFile(file.data.data as FileDetails); + return file.data.type === "file" && viewer.filterFile(file.data.file); } - function scrollToFileClick(event: Event, index: number) { - const element: HTMLElement = event.target as HTMLElement; - // Don't scroll if we clicked the inner checkbox - if (element.tagName.toLowerCase() !== "input") { - viewer.scrollToFile(index); - } + function shouldScrollToFile(nodeInteractionEvent: Event): boolean { + const element: HTMLElement = nodeInteractionEvent.target as HTMLElement; + // Don't scroll/etc. if we clicked the inner checkbox + return element.tagName.toLowerCase() !== "input"; } function focusFileDoubleClick(value: FileDetails): Attachment { return (div) => { const destroyDblclick = on(div, "dblclick", (event) => { - const element: HTMLElement = event.target as HTMLElement; - if (element.tagName.toLowerCase() !== "input") { - viewer.scrollToFile(value.index, { focus: true }); - viewer.setSelection(value, undefined); - if (!staticSidebar.current) { - viewer.layoutState.sidebarCollapsed = true; - } + if (!shouldScrollToFile(event)) return; + viewer.scrollToFile(value.index, { focus: true }); + viewer.setSelection(value, undefined); + if (!staticSidebar.current) { + viewer.layoutState.sidebarCollapsed = true; } }); const destroyMousedown = on(div, "mousedown", (event) => { - const element: HTMLElement = event.target as HTMLElement; - if (element.tagName.toLowerCase() !== "input" && event.detail === 2) { + if (!shouldScrollToFile(event)) return; + if (event.detail === 2) { // Don't select text on double click event.preventDefault(); } @@ -46,8 +42,59 @@ }; }; } + + function nodeProps(data: FileTreeNodeData, collapsed: boolean, toggleCollapse: () => void) { + if (data.type === "file") { + const file = data.file; + return { + id: `file-tree-file-${file.index}`, + "data-selected": boolAttr(viewer.selection?.file.index === file.index), + onclick: (e: MouseEvent) => shouldScrollToFile(e) && viewer.scrollToFile(file.index), + onkeydown: (e: KeyboardEvent) => e.key === "Enter" && shouldScrollToFile(e) && viewer.scrollToFile(file.index), + [createAttachmentKey()]: focusFileDoubleClick(file), + }; + } else if (data.type === "directory") { + return { + onclick: toggleCollapse, + onkeydown: (e: KeyboardEvent) => e.key === "Enter" && toggleCollapse(), + "aria-expanded": !collapsed, + }; + } + return {}; + } +{#snippet renderFileNode(value: FileDetails)} +
+ + {value.toFile.substring(value.toFile.lastIndexOf("/") + 1)} + viewer.toggleChecked(value.index)} + checked={viewer.fileStates[value.index].checked} + /> +
+{/snippet} + +{#snippet renderFolderNode(name: string, collapsed: boolean)} + {@const folderIcon = collapsed ? "octicon--file-directory-fill-16" : "octicon--file-directory-open-fill-16"} +
+ + {name} + {#if collapsed} + + {:else} + + {/if} +
+{/snippet} +
@@ -75,72 +122,38 @@ {/if}
- {#snippet fileSnippet(value: FileDetails)} -
scrollToFileClick(e, value.index)} - {@attach focusFileDoubleClick(value)} - onkeydown={(e) => e.key === "Enter" && viewer.scrollToFile(value.index)} - role="button" - tabindex="0" - id={"file-tree-file-" + value.index} - data-selected={boolAttr(viewer.selection?.file.index === value.index)} - > - - {value.toFile.substring(value.toFile.lastIndexOf("/") + 1)} - viewer.toggleChecked(value.index)} - checked={viewer.fileStates[value.index].checked} - /> -
- {/snippet} {#snippet nodeRenderer({ node, collapsed, toggleCollapse })} - {@const folderIcon = collapsed ? "octicon--file-directory-fill-16" : "octicon--file-directory-open-fill-16"} - {#if node.data.type === "file"} - {@render fileSnippet(node.data.data as FileDetails)} - {:else} -
e.key === "Enter" && toggleCollapse()} - role="button" - tabindex="0" - > - - {node.data.data} - {#if collapsed} - - {:else} - - {/if} -
- {/if} - {/snippet} - {#snippet childWrapper({ node, collapsed, children })}
- {@render children({ node })} + {#if node.data.type === "file"} + {@render renderFileNode(node.data.file)} + {:else} + {@render renderFolderNode(node.data.name, collapsed)} + {/if}
{/snippet} + {#snippet childWrapper({ node, collapsed, children })} + {#if node.visibleChildren.length > 0} +
+ {@render children({ node })} +
+ {/if} + {/snippet}