Skip to content

Commit dd93e61

Browse files
authored
Make sidebar resizable (#45)
1 parent be148e1 commit dd93e61

File tree

14 files changed

+279
-95
lines changed

14 files changed

+279
-95
lines changed

bun.lock

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"chroma-js": "^3.1.2",
1414
"diff": "^8.0.2",
1515
"luxon": "^3.7.2",
16+
"paneforge": "^1.0.2",
1617
"runed": "^0.36.0",
1718
"shiki": "^3.15.0",
1819
"svelte-toolbelt": "^0.10.6",
@@ -882,6 +883,8 @@
882883

883884
"package-manager-detector": ["package-manager-detector@1.3.0", "", {}, "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ=="],
884885

886+
"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=="],
887+
885888
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
886889

887890
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
@@ -1196,6 +1199,10 @@
11961199

11971200
"minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
11981201

1202+
"paneforge/runed": ["runed@0.23.4", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA=="],
1203+
1204+
"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=="],
1205+
11991206
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
12001207

12011208
"svelte-check/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
@@ -1224,6 +1231,8 @@
12241231

12251232
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
12261233

1234+
"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=="],
1235+
12271236
"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
12281237

12291238
"wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"chroma-js": "^3.1.2",
5353
"diff": "^8.0.2",
5454
"luxon": "^3.7.2",
55+
"paneforge": "^1.0.2",
5556
"runed": "^0.36.0",
5657
"shiki": "^3.15.0",
5758
"svelte-toolbelt": "^0.10.6",

web/src/routes/SidebarToggle.svelte renamed to web/src/lib/components/SidebarToggle.svelte

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,25 +12,19 @@
1212
let mergedProps = $derived(
1313
mergeProps(
1414
{
15+
title: viewer.layoutState.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar",
1516
class: "flex size-6 items-center justify-center rounded-md btn-ghost text-primary",
1617
},
1718
restProps,
1819
),
1920
);
2021
</script>
2122

22-
<Button.Root
23-
title={viewer.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
24-
type="button"
25-
data-side={globalOptions.sidebarLocation}
26-
onclick={() => (viewer.sidebarCollapsed = !viewer.sidebarCollapsed)}
27-
data-sidebar-toggle
28-
{...mergedProps}
29-
>
23+
<Button.Root type="button" data-side={globalOptions.sidebarLocation} onclick={() => viewer.layoutState.toggleSidebar()} data-sidebar-toggle {...mergedProps}>
3024
<span
3125
class="iconify size-4 shrink-0 octicon--sidebar-collapse-16 data-[collapsed=false]:octicon--sidebar-expand-16 data-[side=right]:scale-x-[-1]"
3226
aria-hidden="true"
33-
data-collapsed={viewer.sidebarCollapsed}
27+
data-collapsed={viewer.layoutState.sidebarCollapsed}
3428
data-side={globalOptions.sidebarLocation}
3529
></span>
3630
</Button.Root>

web/src/lib/components/menu-bar/MenuBar.svelte

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
import { MultiFileDiffViewerState } from "$lib/diff-viewer.svelte";
33
import { Keybinds } from "$lib/keybinds.svelte";
44
import { Menubar, Button } from "bits-ui";
5+
import SidebarToggle from "$lib/components/SidebarToggle.svelte";
56
67
const viewer = MultiFileDiffViewerState.get();
78
</script>
89

910
{#snippet keybind(key: string)}
10-
<span class="text-em-med">{Keybinds.getModifierKey()}+{key}</span>
11+
<span class="text-em-med">{Keybinds.formatModifierBind(key)}</span>
1112
{/snippet}
1213

1314
<Menubar.Root class="flex border-b leading-none">
@@ -97,7 +98,25 @@
9798
>
9899
Collapse All
99100
</Menubar.Item>
101+
<Menubar.Item
102+
class="flex justify-between gap-2 btn-ghost px-2 py-1 select-none"
103+
onSelect={() => {
104+
viewer.layoutState.toggleSidebar();
105+
}}
106+
>
107+
Toggle Sidebar
108+
{@render keybind("B")}
109+
</Menubar.Item>
110+
<Menubar.Item
111+
class="btn-ghost px-2 py-1 select-none"
112+
onSelect={() => {
113+
viewer.layoutState.resetLayout();
114+
}}
115+
>
116+
Reset Layout
117+
</Menubar.Item>
100118
</Menubar.Content>
101119
</Menubar.Portal>
102120
</Menubar.Menu>
121+
<SidebarToggle class="my-auto mr-2 ml-auto" />
103122
</Menubar.Root>

web/src/lib/diff-viewer.svelte.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Context, Debounced, watch } from "runed";
1818
import { MediaQuery } from "svelte/reactivity";
1919
import { ProgressBarState } from "$lib/components/progress-bar/index.svelte";
2020
import { Keybinds } from "./keybinds.svelte";
21+
import { LayoutState, type PersistentLayoutState } from "./layout.svelte";
2122

2223
export const GITHUB_URL_PARAM = "github_url";
2324
export const PATCH_URL_PARAM = "patch_url";
@@ -200,8 +201,8 @@ export type DiffMetadata = GithubDiffMetadata | FileDiffMetadata;
200201
export class MultiFileDiffViewerState {
201202
private static readonly context = new Context<MultiFileDiffViewerState>("MultiFileDiffViewerState");
202203

203-
static init() {
204-
return MultiFileDiffViewerState.context.set(new MultiFileDiffViewerState());
204+
static init(layoutState: PersistentLayoutState | null) {
205+
return MultiFileDiffViewerState.context.set(new MultiFileDiffViewerState(layoutState));
205206
}
206207

207208
static get() {
@@ -232,20 +233,23 @@ export class MultiFileDiffViewerState {
232233
diffViewCache: Map<FileDetails, ConciseDiffViewCachedState> = new Map();
233234
vlist: VList<FileDetails> | undefined = $state();
234235
readonly loadingState: LoadingState = $state(new LoadingState());
236+
readonly layoutState;
235237

236238
// Transient state
237-
sidebarCollapsed = $state(false);
238239
openDiffDialogOpen = $state(false);
239240
settingsDialogOpen = $state(false);
240241
activeSearchResult: ActiveSearchResult | null = $state(null);
241242

242-
private constructor() {
243+
private constructor(layoutState: PersistentLayoutState | null) {
244+
this.layoutState = new LayoutState(layoutState);
245+
243246
// Make sure to revoke object URLs when the component is destroyed
244247
onDestroy(() => this.clearImages());
245248

246249
const keybinds = new Keybinds();
247250
keybinds.registerModifierBind("o", () => this.openOpenDiffDialog());
248251
keybinds.registerModifierBind(",", () => this.openSettingsDialog());
252+
keybinds.registerModifierBind("b", () => this.layoutState.toggleSidebar());
249253
}
250254

251255
openOpenDiffDialog() {

web/src/lib/global-options.svelte.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { BundledTheme } from "shiki";
22
import { browser } from "$app/environment";
33
import { getEffectiveGlobalTheme } from "$lib/theme.svelte";
4-
import { watchLocalStorage } from "$lib/util";
4+
import { setCookie, watchLocalStorage } from "$lib/util";
55
import { Context } from "runed";
66

77
export const DEFAULT_THEME_LIGHT: BundledTheme = "github-light-default";
@@ -68,7 +68,7 @@ export class GlobalOptions {
6868
return;
6969
}
7070
localStorage.setItem(GlobalOptions.key, this.serialize());
71-
document.cookie = `${GlobalOptions.key}=${encodeURIComponent(this.serializeCookie())}; path=/; max-age=31536000; SameSite=Lax`;
71+
setCookie(GlobalOptions.key, this.serializeCookie());
7272
}
7373

7474
private serialize() {
@@ -98,8 +98,20 @@ export class GlobalOptions {
9898
}
9999

100100
private deserialize(serialized: string) {
101-
const jsonObject = JSON.parse(serialized);
102-
if (jsonObject.syntaxHighlighting !== undefined) {
101+
try {
102+
const jsonObject = JSON.parse(serialized);
103+
this.loadFrom(jsonObject);
104+
} catch {
105+
// Ignore invalid options
106+
}
107+
}
108+
109+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
110+
private loadFrom(jsonObject: any) {
111+
if (jsonObject === undefined || jsonObject === null) {
112+
return;
113+
}
114+
if (typeof jsonObject.syntaxHighlighting === "boolean") {
103115
this.syntaxHighlighting = jsonObject.syntaxHighlighting;
104116
}
105117
if (jsonObject.syntaxHighlightingThemeLight !== undefined) {
@@ -112,13 +124,13 @@ export class GlobalOptions {
112124
} else {
113125
this.syntaxHighlightingThemeDark = DEFAULT_THEME_DARK;
114126
}
115-
if (jsonObject.omitPatchHeaderOnlyHunks !== undefined) {
127+
if (typeof jsonObject.omitPatchHeaderOnlyHunks === "boolean") {
116128
this.omitPatchHeaderOnlyHunks = jsonObject.omitPatchHeaderOnlyHunks;
117129
}
118-
if (jsonObject.wordDiff !== undefined) {
130+
if (typeof jsonObject.wordDiff === "boolean") {
119131
this.wordDiffs = jsonObject.wordDiff;
120132
}
121-
if (jsonObject.lineWrap !== undefined) {
133+
if (typeof jsonObject.lineWrap === "boolean") {
122134
this.lineWrap = jsonObject.lineWrap;
123135
}
124136
if (jsonObject.sidebarLocation !== undefined) {

web/src/lib/keybinds.svelte.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import { on } from "svelte/events";
44
export class Keybinds {
55
private static readonly IS_MAC = typeof navigator !== "undefined" && navigator.userAgent.includes("Mac");
66

7-
static getModifierKey() {
7+
private static formatModifierKey() {
88
return Keybinds.IS_MAC ? "⌘" : "Ctrl";
99
}
1010

11+
static formatModifierBind(key: string) {
12+
return `${Keybinds.formatModifierKey()}+${key}`;
13+
}
14+
1115
private readonly binds = new Map<string, () => void>();
1216

1317
constructor() {

web/src/lib/layout.svelte.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { Pane } from "paneforge";
2+
import { clearCookie, setCookie } from "./util";
3+
import { watch } from "runed";
4+
5+
export const ROOT_LAYOUT_KEY = "diff-viewer-root-layout";
6+
export interface PersistentLayoutState {
7+
sidebarWidth: number;
8+
}
9+
10+
export class LayoutState {
11+
sidebarCollapsed = $state(false);
12+
13+
windowInnerWidth: number | undefined = $state();
14+
sidebarPane: Pane | undefined = $state();
15+
lastSidebarWidth: number | undefined = $state();
16+
17+
minSidebarWidth = $derived.by(() => {
18+
return this.getContainerProportion(200, 0);
19+
});
20+
defaultSidebarWidth = $derived.by(() => {
21+
if (this.lastSidebarWidth !== undefined) {
22+
return this.lastSidebarWidth;
23+
}
24+
return this.getContainerProportion(350, 0.25);
25+
});
26+
27+
defaultMainWidth = $derived.by(() => {
28+
if (this.lastSidebarWidth !== undefined) {
29+
return 100 - this.lastSidebarWidth;
30+
}
31+
return undefined;
32+
});
33+
34+
constructor(persistentState: PersistentLayoutState | null) {
35+
this.loadFrom(persistentState);
36+
37+
// Maintain sidebar size when resizing window
38+
watch.pre(
39+
() => this.windowInnerWidth,
40+
(newValue, oldValue) => {
41+
if (oldValue !== undefined && newValue !== undefined && this.sidebarPane) {
42+
const oldPx = (this.sidebarPane.getSize() / 100) * oldValue;
43+
const newProportion = this.getProportion(oldPx, newValue);
44+
this.sidebarPane.resize(newProportion);
45+
}
46+
},
47+
);
48+
}
49+
50+
private loadFrom(persistentState: PersistentLayoutState | null) {
51+
if (persistentState === null) {
52+
return;
53+
}
54+
55+
const sidebarWidth = persistentState.sidebarWidth;
56+
if (Number.isFinite(sidebarWidth) && sidebarWidth >= 0 && sidebarWidth <= 100) {
57+
this.lastSidebarWidth = sidebarWidth;
58+
}
59+
}
60+
61+
toggleSidebar() {
62+
this.sidebarCollapsed = !this.sidebarCollapsed;
63+
}
64+
65+
private getContainerProportion(px: number, defaultValue: number) {
66+
if (this.windowInnerWidth === undefined) {
67+
return defaultValue;
68+
}
69+
return this.getProportion(px, this.windowInnerWidth);
70+
}
71+
72+
private getProportion(px: number, max: number) {
73+
return Math.max(0, Math.min(100, (px / max) * 100));
74+
}
75+
76+
resetLayout() {
77+
clearCookie(ROOT_LAYOUT_KEY);
78+
this.lastSidebarWidth = undefined;
79+
if (this.sidebarPane) {
80+
this.sidebarPane.resize(this.defaultSidebarWidth);
81+
}
82+
}
83+
84+
onSidebarResize(size: number, prevSize: number | undefined) {
85+
if (prevSize === undefined) {
86+
// Prevent initial resize from triggering update loop
87+
return;
88+
}
89+
90+
/*
91+
TODO:
92+
*also* persist size in px to avoid sidebar changing size when reopening with
93+
a different sized window
94+
95+
need to keep the proportion for SSR as paneforge does not currently provide
96+
a way to preset a size in px (it generally works in proportions only)
97+
98+
this means there may be a shift on hydration when a new window uses an old cookie
99+
100+
see GH:svecosystem/paneforge/issues/91
101+
*/
102+
this.lastSidebarWidth = size;
103+
const rootLayout: PersistentLayoutState = {
104+
sidebarWidth: this.lastSidebarWidth,
105+
};
106+
setCookie(ROOT_LAYOUT_KEY, JSON.stringify(rootLayout));
107+
}
108+
}

web/src/lib/util.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ export type MutableValue<T> = {
1313
value: T;
1414
};
1515

16+
export function clearCookie(name: string) {
17+
document.cookie = name + "=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT";
18+
}
19+
20+
export function setCookie(name: string, value: string) {
21+
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=31536000; SameSite=Lax`;
22+
}
23+
1624
function isFullCommitHash(s: string): boolean {
1725
return /^[0-9a-fA-F]{40}$/.test(s);
1826
}

web/src/routes/+layout.svelte

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
import "../app.css";
33
import { initThemeHooks } from "$lib/theme.svelte";
44
import { Tooltip } from "bits-ui";
5+
import { type LayoutProps } from "./$types";
6+
import { GlobalOptions } from "$lib/global-options.svelte";
57
6-
let { children } = $props();
8+
let { children, data }: LayoutProps = $props();
9+
GlobalOptions.init(data.globalOptions);
710
811
initThemeHooks();
912
</script>

0 commit comments

Comments
 (0)