Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -882,6 +883,8 @@

"package-manager-detector": ["[email protected]", "", {}, "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ=="],

"paneforge": ["[email protected]", "", { "dependencies": { "runed": "^0.23.4", "svelte-toolbelt": "^0.9.2" }, "peerDependencies": { "svelte": "^5.29.0" } }, "sha512-KzmIXQH1wCfwZ4RsMohD/IUtEjVhteR+c+ulb/CHYJHX8SuDXoJmChtsc/Xs5Wl8NHS4L5Q7cxL8MG40gSU1bA=="],

"parent-module": ["[email protected]", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],

"parse5": ["[email protected]", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
Expand Down Expand Up @@ -1196,6 +1199,10 @@

"minizlib/minipass": ["[email protected]", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],

"paneforge/runed": ["[email protected]", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA=="],

"paneforge/svelte-toolbelt": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],

"svelte-check/fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
Expand Down Expand Up @@ -1224,6 +1231,8 @@

"csso/css-tree/mdn-data": ["[email protected]", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],

"paneforge/svelte-toolbelt/runed": ["[email protected]", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA=="],

"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],

"wrangler/esbuild/@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
Expand Down
1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,19 @@
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,
),
);
</script>

<Button.Root
title={viewer.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
type="button"
data-side={globalOptions.sidebarLocation}
onclick={() => (viewer.sidebarCollapsed = !viewer.sidebarCollapsed)}
data-sidebar-toggle
{...mergedProps}
>
<Button.Root type="button" data-side={globalOptions.sidebarLocation} onclick={() => viewer.layoutState.toggleSidebar()} data-sidebar-toggle {...mergedProps}>
<span
class="iconify size-4 shrink-0 octicon--sidebar-collapse-16 data-[collapsed=false]:octicon--sidebar-expand-16 data-[side=right]:scale-x-[-1]"
aria-hidden="true"
data-collapsed={viewer.sidebarCollapsed}
data-collapsed={viewer.layoutState.sidebarCollapsed}
data-side={globalOptions.sidebarLocation}
></span>
</Button.Root>
21 changes: 20 additions & 1 deletion web/src/lib/components/menu-bar/MenuBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();
</script>

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

<Menubar.Root class="flex border-b leading-none">
Expand Down Expand Up @@ -97,7 +98,25 @@
>
Collapse All
</Menubar.Item>
<Menubar.Item
class="flex justify-between gap-2 btn-ghost px-2 py-1 select-none"
onSelect={() => {
viewer.layoutState.toggleSidebar();
}}
>
Toggle Sidebar
{@render keybind("B")}
</Menubar.Item>
<Menubar.Item
class="btn-ghost px-2 py-1 select-none"
onSelect={() => {
viewer.layoutState.resetLayout();
}}
>
Reset Layout
</Menubar.Item>
</Menubar.Content>
</Menubar.Portal>
</Menubar.Menu>
<SidebarToggle class="my-auto mr-2 ml-auto" />
</Menubar.Root>
12 changes: 8 additions & 4 deletions web/src/lib/diff-viewer.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -200,8 +201,8 @@ export type DiffMetadata = GithubDiffMetadata | FileDiffMetadata;
export class MultiFileDiffViewerState {
private static readonly context = new Context<MultiFileDiffViewerState>("MultiFileDiffViewerState");

static init() {
return MultiFileDiffViewerState.context.set(new MultiFileDiffViewerState());
static init(layoutState: PersistentLayoutState | null) {
return MultiFileDiffViewerState.context.set(new MultiFileDiffViewerState(layoutState));
}

static get() {
Expand Down Expand Up @@ -232,20 +233,23 @@ export class MultiFileDiffViewerState {
diffViewCache: Map<FileDetails, ConciseDiffViewCachedState> = new Map();
vlist: VList<FileDetails> | 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() {
Expand Down
26 changes: 19 additions & 7 deletions web/src/lib/global-options.svelte.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion web/src/lib/keybinds.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, () => void>();

constructor() {
Expand Down
108 changes: 108 additions & 0 deletions web/src/lib/layout.svelte.ts
Original file line number Diff line number Diff line change
@@ -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));
}
}
8 changes: 8 additions & 0 deletions web/src/lib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export type MutableValue<T> = {
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);
}
Expand Down
5 changes: 4 additions & 1 deletion web/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();
</script>
Expand Down
17 changes: 17 additions & 0 deletions web/src/routes/+page.server.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
Loading