diff --git a/bun.lock b/bun.lock index 5029b3b..ab72829 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "chroma-js": "^3.1.2", "diff": "^8.0.2", "luxon": "^3.7.2", + "paneforge": "^1.0.2", "runed": "^0.36.0", "shiki": "^3.15.0", "svelte-toolbelt": "^0.10.6", @@ -882,6 +883,8 @@ "package-manager-detector": ["package-manager-detector@1.3.0", "", {}, "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ=="], + "paneforge": ["paneforge@1.0.2", "", { "dependencies": { "runed": "^0.23.4", "svelte-toolbelt": "^0.9.2" }, "peerDependencies": { "svelte": "^5.29.0" } }, "sha512-KzmIXQH1wCfwZ4RsMohD/IUtEjVhteR+c+ulb/CHYJHX8SuDXoJmChtsc/Xs5Wl8NHS4L5Q7cxL8MG40gSU1bA=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -1196,6 +1199,10 @@ "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "paneforge/runed": ["runed@0.23.4", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA=="], + + "paneforge/svelte-toolbelt": ["svelte-toolbelt@0.9.3", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.29.0", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "svelte-check/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], @@ -1224,6 +1231,8 @@ "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + "paneforge/svelte-toolbelt/runed": ["runed@0.29.2", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA=="], + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], diff --git a/web/package.json b/web/package.json index e8909ec..e4f89ba 100644 --- a/web/package.json +++ b/web/package.json @@ -52,6 +52,7 @@ "chroma-js": "^3.1.2", "diff": "^8.0.2", "luxon": "^3.7.2", + "paneforge": "^1.0.2", "runed": "^0.36.0", "shiki": "^3.15.0", "svelte-toolbelt": "^0.10.6", diff --git a/web/src/routes/SidebarToggle.svelte b/web/src/lib/components/SidebarToggle.svelte similarity index 73% rename from web/src/routes/SidebarToggle.svelte rename to web/src/lib/components/SidebarToggle.svelte index ade5aab..33b9120 100644 --- a/web/src/routes/SidebarToggle.svelte +++ b/web/src/lib/components/SidebarToggle.svelte @@ -12,6 +12,7 @@ let mergedProps = $derived( mergeProps( { + title: viewer.layoutState.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar", class: "flex size-6 items-center justify-center rounded-md btn-ghost text-primary", }, restProps, @@ -19,18 +20,11 @@ ); - (viewer.sidebarCollapsed = !viewer.sidebarCollapsed)} - data-sidebar-toggle - {...mergedProps} -> + viewer.layoutState.toggleSidebar()} data-sidebar-toggle {...mergedProps}> diff --git a/web/src/lib/components/menu-bar/MenuBar.svelte b/web/src/lib/components/menu-bar/MenuBar.svelte index 0e27ddc..924c808 100644 --- a/web/src/lib/components/menu-bar/MenuBar.svelte +++ b/web/src/lib/components/menu-bar/MenuBar.svelte @@ -2,12 +2,13 @@ import { MultiFileDiffViewerState } from "$lib/diff-viewer.svelte"; import { Keybinds } from "$lib/keybinds.svelte"; import { Menubar, Button } from "bits-ui"; + import SidebarToggle from "$lib/components/SidebarToggle.svelte"; const viewer = MultiFileDiffViewerState.get(); {#snippet keybind(key: string)} - {Keybinds.getModifierKey()}+{key} + {Keybinds.formatModifierBind(key)} {/snippet} @@ -97,7 +98,25 @@ > Collapse All + { + viewer.layoutState.toggleSidebar(); + }} + > + Toggle Sidebar + {@render keybind("B")} + + { + viewer.layoutState.resetLayout(); + }} + > + Reset Layout + + diff --git a/web/src/lib/diff-viewer.svelte.ts b/web/src/lib/diff-viewer.svelte.ts index 41ff4d7..7dfa5a2 100644 --- a/web/src/lib/diff-viewer.svelte.ts +++ b/web/src/lib/diff-viewer.svelte.ts @@ -18,6 +18,7 @@ import { Context, Debounced, watch } from "runed"; import { MediaQuery } from "svelte/reactivity"; import { ProgressBarState } from "$lib/components/progress-bar/index.svelte"; import { Keybinds } from "./keybinds.svelte"; +import { LayoutState, type PersistentLayoutState } from "./layout.svelte"; export const GITHUB_URL_PARAM = "github_url"; export const PATCH_URL_PARAM = "patch_url"; @@ -200,8 +201,8 @@ export type DiffMetadata = GithubDiffMetadata | FileDiffMetadata; export class MultiFileDiffViewerState { private static readonly context = new Context("MultiFileDiffViewerState"); - static init() { - return MultiFileDiffViewerState.context.set(new MultiFileDiffViewerState()); + static init(layoutState: PersistentLayoutState | null) { + return MultiFileDiffViewerState.context.set(new MultiFileDiffViewerState(layoutState)); } static get() { @@ -232,20 +233,23 @@ export class MultiFileDiffViewerState { diffViewCache: Map = new Map(); vlist: VList | undefined = $state(); readonly loadingState: LoadingState = $state(new LoadingState()); + readonly layoutState; // Transient state - sidebarCollapsed = $state(false); openDiffDialogOpen = $state(false); settingsDialogOpen = $state(false); activeSearchResult: ActiveSearchResult | null = $state(null); - private constructor() { + private constructor(layoutState: PersistentLayoutState | null) { + this.layoutState = new LayoutState(layoutState); + // Make sure to revoke object URLs when the component is destroyed onDestroy(() => this.clearImages()); const keybinds = new Keybinds(); keybinds.registerModifierBind("o", () => this.openOpenDiffDialog()); keybinds.registerModifierBind(",", () => this.openSettingsDialog()); + keybinds.registerModifierBind("b", () => this.layoutState.toggleSidebar()); } openOpenDiffDialog() { diff --git a/web/src/lib/global-options.svelte.ts b/web/src/lib/global-options.svelte.ts index 1e02bd9..c10f5aa 100644 --- a/web/src/lib/global-options.svelte.ts +++ b/web/src/lib/global-options.svelte.ts @@ -1,7 +1,7 @@ import type { BundledTheme } from "shiki"; import { browser } from "$app/environment"; import { getEffectiveGlobalTheme } from "$lib/theme.svelte"; -import { watchLocalStorage } from "$lib/util"; +import { setCookie, watchLocalStorage } from "$lib/util"; import { Context } from "runed"; export const DEFAULT_THEME_LIGHT: BundledTheme = "github-light-default"; @@ -68,7 +68,7 @@ export class GlobalOptions { return; } localStorage.setItem(GlobalOptions.key, this.serialize()); - document.cookie = `${GlobalOptions.key}=${encodeURIComponent(this.serializeCookie())}; path=/; max-age=31536000; SameSite=Lax`; + setCookie(GlobalOptions.key, this.serializeCookie()); } private serialize() { @@ -98,8 +98,20 @@ export class GlobalOptions { } private deserialize(serialized: string) { - const jsonObject = JSON.parse(serialized); - if (jsonObject.syntaxHighlighting !== undefined) { + try { + const jsonObject = JSON.parse(serialized); + this.loadFrom(jsonObject); + } catch { + // Ignore invalid options + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private loadFrom(jsonObject: any) { + if (jsonObject === undefined || jsonObject === null) { + return; + } + if (typeof jsonObject.syntaxHighlighting === "boolean") { this.syntaxHighlighting = jsonObject.syntaxHighlighting; } if (jsonObject.syntaxHighlightingThemeLight !== undefined) { @@ -112,13 +124,13 @@ export class GlobalOptions { } else { this.syntaxHighlightingThemeDark = DEFAULT_THEME_DARK; } - if (jsonObject.omitPatchHeaderOnlyHunks !== undefined) { + if (typeof jsonObject.omitPatchHeaderOnlyHunks === "boolean") { this.omitPatchHeaderOnlyHunks = jsonObject.omitPatchHeaderOnlyHunks; } - if (jsonObject.wordDiff !== undefined) { + if (typeof jsonObject.wordDiff === "boolean") { this.wordDiffs = jsonObject.wordDiff; } - if (jsonObject.lineWrap !== undefined) { + if (typeof jsonObject.lineWrap === "boolean") { this.lineWrap = jsonObject.lineWrap; } if (jsonObject.sidebarLocation !== undefined) { diff --git a/web/src/lib/keybinds.svelte.ts b/web/src/lib/keybinds.svelte.ts index a11c73b..a8099bd 100644 --- a/web/src/lib/keybinds.svelte.ts +++ b/web/src/lib/keybinds.svelte.ts @@ -4,10 +4,14 @@ import { on } from "svelte/events"; export class Keybinds { private static readonly IS_MAC = typeof navigator !== "undefined" && navigator.userAgent.includes("Mac"); - static getModifierKey() { + private static formatModifierKey() { return Keybinds.IS_MAC ? "⌘" : "Ctrl"; } + static formatModifierBind(key: string) { + return `${Keybinds.formatModifierKey()}+${key}`; + } + private readonly binds = new Map void>(); constructor() { diff --git a/web/src/lib/layout.svelte.ts b/web/src/lib/layout.svelte.ts new file mode 100644 index 0000000..31c6fcf --- /dev/null +++ b/web/src/lib/layout.svelte.ts @@ -0,0 +1,108 @@ +import type { Pane } from "paneforge"; +import { clearCookie, setCookie } from "./util"; +import { watch } from "runed"; + +export const ROOT_LAYOUT_KEY = "diff-viewer-root-layout"; +export interface PersistentLayoutState { + sidebarWidth: number; +} + +export class LayoutState { + sidebarCollapsed = $state(false); + + windowInnerWidth: number | undefined = $state(); + sidebarPane: Pane | undefined = $state(); + lastSidebarWidth: number | undefined = $state(); + + minSidebarWidth = $derived.by(() => { + return this.getContainerProportion(200, 0); + }); + defaultSidebarWidth = $derived.by(() => { + if (this.lastSidebarWidth !== undefined) { + return this.lastSidebarWidth; + } + return this.getContainerProportion(350, 0.25); + }); + + defaultMainWidth = $derived.by(() => { + if (this.lastSidebarWidth !== undefined) { + return 100 - this.lastSidebarWidth; + } + return undefined; + }); + + constructor(persistentState: PersistentLayoutState | null) { + this.loadFrom(persistentState); + + // Maintain sidebar size when resizing window + watch.pre( + () => this.windowInnerWidth, + (newValue, oldValue) => { + if (oldValue !== undefined && newValue !== undefined && this.sidebarPane) { + const oldPx = (this.sidebarPane.getSize() / 100) * oldValue; + const newProportion = this.getProportion(oldPx, newValue); + this.sidebarPane.resize(newProportion); + } + }, + ); + } + + private loadFrom(persistentState: PersistentLayoutState | null) { + if (persistentState === null) { + return; + } + + const sidebarWidth = persistentState.sidebarWidth; + if (Number.isFinite(sidebarWidth) && sidebarWidth >= 0 && sidebarWidth <= 100) { + this.lastSidebarWidth = sidebarWidth; + } + } + + toggleSidebar() { + this.sidebarCollapsed = !this.sidebarCollapsed; + } + + private getContainerProportion(px: number, defaultValue: number) { + if (this.windowInnerWidth === undefined) { + return defaultValue; + } + return this.getProportion(px, this.windowInnerWidth); + } + + private getProportion(px: number, max: number) { + return Math.max(0, Math.min(100, (px / max) * 100)); + } + + resetLayout() { + clearCookie(ROOT_LAYOUT_KEY); + this.lastSidebarWidth = undefined; + if (this.sidebarPane) { + this.sidebarPane.resize(this.defaultSidebarWidth); + } + } + + onSidebarResize(size: number, prevSize: number | undefined) { + if (prevSize === undefined) { + // Prevent initial resize from triggering update loop + return; + } + + /* + TODO: + *also* persist size in px to avoid sidebar changing size when reopening with + a different sized window + + need to keep the proportion for SSR as paneforge does not currently provide + a way to preset a size in px (it generally works in proportions only) + + this means there may be a shift on hydration when a new window uses an old cookie + + see GH:svecosystem/paneforge/issues/91 + */ + this.lastSidebarWidth = size; + const rootLayout: PersistentLayoutState = { + sidebarWidth: this.lastSidebarWidth, + }; + setCookie(ROOT_LAYOUT_KEY, JSON.stringify(rootLayout)); + } +} diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index d882900..ba1fb67 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -13,6 +13,14 @@ export type MutableValue = { value: T; }; +export function clearCookie(name: string) { + document.cookie = name + "=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"; +} + +export function setCookie(name: string, value: string) { + document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=31536000; SameSite=Lax`; +} + function isFullCommitHash(s: string): boolean { return /^[0-9a-fA-F]{40}$/.test(s); } diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 1ea074d..86400cc 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -2,8 +2,11 @@ import "../app.css"; import { initThemeHooks } from "$lib/theme.svelte"; import { Tooltip } from "bits-ui"; + import { type LayoutProps } from "./$types"; + import { GlobalOptions } from "$lib/global-options.svelte"; - let { children } = $props(); + let { children, data }: LayoutProps = $props(); + GlobalOptions.init(data.globalOptions); initThemeHooks(); diff --git a/web/src/routes/+page.server.ts b/web/src/routes/+page.server.ts new file mode 100644 index 0000000..bdf5d44 --- /dev/null +++ b/web/src/routes/+page.server.ts @@ -0,0 +1,17 @@ +import { ROOT_LAYOUT_KEY, type PersistentLayoutState } from "$lib/layout.svelte"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ cookies }) => { + const cookie = cookies.get(ROOT_LAYOUT_KEY); + let parsed: PersistentLayoutState | null = null; + if (cookie) { + try { + parsed = JSON.parse(cookie); + } catch { + // Ignore invalid cookie + } + } + return { + rootLayout: parsed, + }; +}; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index cdb86ec..2c25124 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -6,7 +6,6 @@ import FileHeader from "./FileHeader.svelte"; import DiffTitle from "./DiffTitle.svelte"; import OpenDiffDialog from "./OpenDiffDialog.svelte"; - import SidebarToggle from "./SidebarToggle.svelte"; import type { PageProps } from "./$types"; import ProgressBar from "$lib/components/progress-bar/ProgressBar.svelte"; import { GlobalOptions } from "$lib/global-options.svelte"; @@ -14,10 +13,11 @@ import SettingsDialog from "$lib/components/settings/SettingsDialog.svelte"; import Sidebar from "./Sidebar.svelte"; import DiffWrapper from "./DiffWrapper.svelte"; + import { PaneGroup, Pane, PaneResizer } from "paneforge"; let { data }: PageProps = $props(); - GlobalOptions.init(data.globalOptions); - const viewer = MultiFileDiffViewerState.init(); + const globalOptions = GlobalOptions.get(); + const viewer = MultiFileDiffViewerState.init(data.rootLayout); function getPageTitle() { if (viewer.diffMetadata) { @@ -32,8 +32,6 @@ } let pageTitle = $derived(getPageTitle()); - - let mainContainerRef: HTMLDivElement | null = $state(null); @@ -44,6 +42,8 @@ /> + + {#if viewer.loadingState.loading}
@@ -53,31 +53,75 @@ -
- -
- -
- {#if viewer.diffMetadata !== null} -
- -
- {/if} -
- - - +{#snippet sidebarPane(order: number)} + {#if !viewer.layoutState.sidebarCollapsed} + { + viewer.layoutState.onSidebarResize(size, prevSize); + }} + {order} + > +
+
-
- i} bind:this={viewer.vlist}> - {#snippet children(value, index)} -
- - -
- {/snippet} -
+ + {/if} +{/snippet} + +{#snippet main()} +
+ {#if viewer.diffMetadata !== null} +
+
+ {/if} +
+ +
+
+ i} bind:this={viewer.vlist}> + {#snippet children(value, index)} +
+ + +
+ {/snippet} +
+
+
+{/snippet} + +{#snippet mainPane(order: number)} + + {@render main()} + +{/snippet} + +
+ +
+ + {#if globalOptions.sidebarLocation === "left"} + {@render sidebarPane(1)} + {:else} + {@render mainPane(1)} + {/if} + {#if !viewer.layoutState.sidebarCollapsed} + +
+
+ {/if} + {#if globalOptions.sidebarLocation === "right"} + {@render sidebarPane(2)} + {:else} + {@render mainPane(2)} + {/if} +
diff --git a/web/src/routes/FileHeader.svelte b/web/src/routes/FileHeader.svelte index dfd832b..e59a3b4 100644 --- a/web/src/routes/FileHeader.svelte +++ b/web/src/routes/FileHeader.svelte @@ -18,12 +18,13 @@ let popoverOpen = $state(false); async function showInFileTree() { + viewer.layoutState.sidebarCollapsed = false; + await tick(); + const fileTreeElement = document.getElementById("file-tree-file-" + index); if (fileTreeElement) { popoverOpen = false; viewer.tree?.expandParents((node) => node.data === value); - viewer.sidebarCollapsed = false; - await tick(); requestAnimationFrame(() => { fileTreeElement.focus(); }); diff --git a/web/src/routes/Sidebar.svelte b/web/src/routes/Sidebar.svelte index ad1be4f..abba6df 100644 --- a/web/src/routes/Sidebar.svelte +++ b/web/src/routes/Sidebar.svelte @@ -5,37 +5,8 @@ import { type TreeNode } from "$lib/components/tree/index.svelte"; import { type Action } from "svelte/action"; import { on } from "svelte/events"; - import SidebarToggle from "./SidebarToggle.svelte"; - import { GlobalOptions } from "$lib/global-options.svelte"; - import { onClickOutside } from "runed"; - - interface Props { - closeOnClick?: HTMLDivElement | null; - } - - let { closeOnClick }: Props = $props(); const viewer = MultiFileDiffViewerState.get(); - const globalOptions = GlobalOptions.get(); - - let sidebarElement: HTMLDivElement | undefined = $state(); - - onClickOutside( - () => sidebarElement, - (e) => { - if (e.target instanceof HTMLElement && e.target.closest("[data-sidebar-toggle]")) { - // Ignore toggle button clicks - return; - } - if (closeOnClick && !(e.target instanceof HTMLElement && closeOnClick.contains(e.target))) { - // Only act if the click was inside the closeOnClick element - return; - } - if (!staticSidebar.current) { - viewer.sidebarCollapsed = true; - } - }, - ); function filterFileNode(file: TreeNode): boolean { return file.data.type === "file" && viewer.filterFile(file.data.data as FileDetails); @@ -55,7 +26,7 @@ if (element.tagName.toLowerCase() !== "input") { viewer.scrollToFile(index, { focus: true }); if (!staticSidebar.current) { - viewer.sidebarCollapsed = true; + viewer.layoutState.sidebarCollapsed = true; } } }); @@ -75,16 +46,7 @@ }; -
+
{/if}
-
{#if viewer.filteredFileDetails.length !== viewer.fileDetails.length}
@@ -188,7 +149,6 @@ top: 0; left: 1rem; background-color: var(--color-gray-500); - z-index: 50; display: block; }