Skip to content

Commit a5c4d38

Browse files
committed
feat(share): copy a link that opens your config
Dotfiles get passed around, and until now the only way to hand someone a config was to send them the file and have them import it. The config goes in the fragment, which browsers do not send, so a link reaches whoever opens it without touching a server here: nothing is stored, there is no id to look up, and no link can expire or be taken back. Only what differs from Kitty's defaults goes in, deflated, which puts a full theme plus a page of settings comfortably inside the length anything will carry. Opening a link replaces what was already there, so it goes through the store as one step and undo puts the previous work back. A link is something somebody else wrote, so it goes through the same sanitiser as an imported file, and anything not shaped like a config is ignored rather than sanitised into an empty one, which would have quietly cleared the editor. Pasting a link into a tab that already has the editor open only changes the fragment and reloads nothing, so that is handled too.
1 parent 709a456 commit a5c4d38

5 files changed

Lines changed: 419 additions & 0 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ the editor. Anything it does not model, whether that is a kitten, an `include`
5858
line, `env`, or an option from a Kitty newer than it knows about, is kept
5959
verbatim and written back out.
6060

61+
**Share** copies a link that opens your config in somebody else's browser. The
62+
config rides in the fragment, the part of a URL browsers never send, so it
63+
reaches them without touching a server here: there is nothing stored, no id to
64+
look up, and no link to expire. Opening one replaces what you had, which undo
65+
puts back.
66+
6167
Pick your Kitty version, anywhere from 0.15 to 0.48, and options that release
6268
does not have are commented out with the version that introduced them instead of
6369
being emitted silently.

src/app/app.component.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { CommonModule } from "@angular/common";
22
import {
33
afterNextRender,
44
Component,
5+
DestroyRef,
56
HostListener,
67
inject,
78
signal,
@@ -11,6 +12,7 @@ import { CategoryNavigationComponent } from "../components/category-navigation/c
1112
import { ConfigEditorComponent } from "../components/config-editor/config-editor.component";
1213
import { HeaderComponent } from "../components/header/header.component";
1314
import { LivePreviewComponent } from "../components/live-preview/live-preview.component";
15+
import { ConfigSharingService } from "../services/config-sharing.service";
1416
import { ConfigStoreService } from "../services/config-store.service";
1517
import { formatCount, SiteStatsService } from "../services/site-stats.service";
1618

@@ -43,6 +45,32 @@ function isTextEntry(target: EventTarget | null): boolean {
4345
<div class="h-dvh bg-kitty-darker text-kitty-text flex flex-col overflow-hidden">
4446
<app-header (aboutRequested)="showAbout.set(true)" />
4547
48+
<!--
49+
Says plainly that what is on screen replaced what was here, and how to
50+
get it back, because the swap happens before anyone has touched anything.
51+
-->
52+
@if (openedFromLink()) {
53+
<div
54+
class="px-3 sm:px-4 lg:px-6 py-2 border-b border-kitty-border bg-kitty-surface-light/60 flex items-center gap-3 text-sm text-kitty-text-dim"
55+
role="status"
56+
>
57+
<span class="flex-1 min-w-0">
58+
Opened from a shared link. Nothing was uploaded; the config travelled
59+
in the address. Undo to go back to your own.
60+
</span>
61+
<button
62+
type="button"
63+
(click)="openedFromLink.set(false)"
64+
class="flex-shrink-0 -m-1.5 p-1.5 opacity-70 hover:opacity-100 transition-opacity"
65+
aria-label="Dismiss message"
66+
>
67+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
68+
<path d="M6 18L18 6M6 6l12 12"/>
69+
</svg>
70+
</button>
71+
</div>
72+
}
73+
4674
@if (showAbout()) {
4775
<app-about-modal (closeRequested)="showAbout.set(false)" />
4876
}
@@ -137,10 +165,48 @@ export class AppComponent {
137165
readonly visitors = this.siteStats.visitors;
138166
readonly format = formatCount;
139167

168+
private readonly sharing = inject(ConfigSharingService);
169+
/** Says where the config came from, since nothing else on screen would. */
170+
readonly openedFromLink = signal(false);
171+
140172
constructor() {
141173
// After the first paint, so a slow or blocked request never delays the
142174
// editor appearing.
143175
afterNextRender(() => void this.siteStats.load());
176+
afterNextRender(() => void this.openSharedConfig());
177+
178+
// Pasting a link into the address bar of a tab that already has the editor
179+
// open only changes the fragment, so nothing reloads and none of the above
180+
// runs again. Without this the link appears to do nothing at all.
181+
const onHashChange = () => void this.openSharedConfig();
182+
globalThis.addEventListener("hashchange", onHashChange);
183+
inject(DestroyRef).onDestroy(() =>
184+
globalThis.removeEventListener("hashchange", onHashChange),
185+
);
186+
}
187+
188+
/**
189+
* Opens a config someone was sent a link to. It replaces whatever was
190+
* restored from the last visit, which is why it goes through the store as a
191+
* single step: Ctrl+Z puts the previous work straight back.
192+
*
193+
* The fragment is cleared afterwards so that editing from here does not leave
194+
* an address bar describing a config that is no longer the one on screen.
195+
*/
196+
private async openSharedConfig(): Promise<void> {
197+
const hash = globalThis.location.hash;
198+
if (!hash.includes("c=")) return;
199+
200+
const shared = await this.sharing.fromHash(hash);
201+
if (!shared) return;
202+
203+
this.configStore.loadConfig(shared);
204+
this.openedFromLink.set(true);
205+
globalThis.history.replaceState(
206+
null,
207+
"",
208+
globalThis.location.pathname + globalThis.location.search,
209+
);
144210
}
145211

146212
/**

src/components/header/header.component.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { CommonModule } from "@angular/common";
22
import { Component, computed, inject, output, signal } from "@angular/core";
33
import { FormsModule } from "@angular/forms";
44
import { DEFAULT_KITTY_CONFIG, SEARCHABLE_OPTION_COUNT } from "../../models/kitty-defaults";
5+
import { ConfigSharingService } from "../../services/config-sharing.service";
56
import { ConfigStoreService } from "../../services/config-store.service";
67
import { KittyGeneratorService } from "../../services/kitty-generator.service";
78
import { KittyParserService } from "../../services/kitty-parser.service";
@@ -235,6 +236,18 @@ function countDirectives(config: KittyConfigAST): number {
235236
{{ configStore.advancedMode() ? 'Advanced' : 'Simple' }}
236237
</button>
237238
239+
<button
240+
(click)="handleShare()"
241+
class="hidden sm:flex items-center gap-2 px-3 py-2 lg:px-4 lg:py-2.5 bg-kitty-surface-light hover:bg-kitty-bg text-kitty-text rounded-lg text-sm font-medium transition-colors border border-kitty-border"
242+
title="Copy a link that opens this config"
243+
>
244+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
245+
<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/>
246+
<path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/>
247+
</svg>
248+
<span class="hidden xl:inline">Share</span>
249+
</button>
250+
238251
<button
239252
(click)="handleImport()"
240253
class="hidden sm:flex items-center gap-2 px-3 py-2 lg:px-4 lg:py-2.5 bg-kitty-surface-light hover:bg-kitty-bg text-kitty-text rounded-lg text-sm font-medium transition-colors border border-kitty-border"
@@ -329,6 +342,10 @@ function countDirectives(config: KittyConfigAST): number {
329342
<svg class="w-4 h-4 text-kitty-text-dim" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
330343
{{ configStore.previewVisible() ? 'Hide Preview' : 'Show Preview' }}
331344
</button>
345+
<button (click)="handleShare(); mobileMenuOpen.set(false)" class="w-full px-3 py-2.5 rounded-lg text-left text-sm text-kitty-text hover:bg-kitty-surface-light transition-all duration-150 flex items-center gap-3 active:scale-[0.98]">
346+
<svg class="w-4 h-4 text-kitty-text-dim" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
347+
Copy Share Link
348+
</button>
332349
<button (click)="handleImport(); mobileMenuOpen.set(false)" class="w-full px-3 py-2.5 rounded-lg text-left text-sm text-kitty-text hover:bg-kitty-surface-light transition-all duration-150 flex items-center gap-3 active:scale-[0.98]">
333350
<svg class="w-4 h-4 text-kitty-text-dim" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>
334351
Import Config
@@ -459,6 +476,7 @@ export class HeaderComponent {
459476
public readonly themeService: ThemeService,
460477
private readonly generator: KittyGeneratorService,
461478
private readonly parser: KittyParserService,
479+
private readonly sharing: ConfigSharingService,
462480
) {}
463481

464482
handleImport(): void {
@@ -507,4 +525,40 @@ export class HeaderComponent {
507525
handleExport(): void {
508526
this.generator.downloadConfig(this.configStore.configState());
509527
}
528+
529+
/**
530+
* The config travels in the link's fragment, so it is never sent anywhere:
531+
* whoever opens it decodes it in their own browser. Nothing is stored here,
532+
* which also means a link cannot be taken back once it is sent.
533+
*/
534+
async handleShare(): Promise<void> {
535+
const link = await this.sharing.toLink(
536+
this.configStore.configState(),
537+
`${globalThis.location.origin}${globalThis.location.pathname}`,
538+
);
539+
540+
if (!link) {
541+
this.importStatus.set({
542+
tone: "error",
543+
message: "This config is too large to put in a link. Export the file instead.",
544+
});
545+
return;
546+
}
547+
548+
try {
549+
await navigator.clipboard.writeText(link);
550+
this.importStatus.set({
551+
tone: "success",
552+
message: "Link copied. It carries your config in the address itself, so nothing was uploaded.",
553+
});
554+
} catch {
555+
// Denied clipboard permission, or an insecure origin. Putting the link
556+
// in the address bar at least leaves it somewhere copyable.
557+
globalThis.location.hash = new URL(link).hash;
558+
this.importStatus.set({
559+
tone: "success",
560+
message: "Link ready in the address bar; copying it was blocked by the browser.",
561+
});
562+
}
563+
}
510564
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { TestBed } from "@angular/core/testing";
2+
import { beforeEach, describe, expect, it } from "vitest";
3+
import { DEFAULT_KITTY_CONFIG } from "../models/kitty-defaults";
4+
import type { KittyConfigAST } from "../models/kitty-types";
5+
import { ConfigSharingService } from "./config-sharing.service";
6+
7+
const BASE = "https://confitty.app/";
8+
9+
describe("ConfigSharingService", () => {
10+
let sharing: ConfigSharingService;
11+
12+
beforeEach(() => {
13+
TestBed.resetTestingModule();
14+
sharing = TestBed.inject(ConfigSharingService);
15+
});
16+
17+
function edited(change: (config: KittyConfigAST) => void): KittyConfigAST {
18+
const config = structuredClone(DEFAULT_KITTY_CONFIG);
19+
change(config);
20+
return config;
21+
}
22+
23+
async function roundTrip(config: KittyConfigAST): Promise<KittyConfigAST> {
24+
const link = await sharing.toLink(config, BASE);
25+
expect(link).not.toBeNull();
26+
const restored = await sharing.fromHash(new URL(link ?? "").hash);
27+
expect(restored).not.toBeNull();
28+
return restored as KittyConfigAST;
29+
}
30+
31+
it("brings a config back out of its own link", async () => {
32+
const config = edited((c) => {
33+
c.fonts.font_family = "JetBrains Mono";
34+
c.fonts.font_size = 15;
35+
c.colors.background = "#101010";
36+
c.keyboard_shortcuts = [{ chord: "ctrl+shift+t", action: "new_tab" }];
37+
});
38+
39+
expect(await roundTrip(config)).toEqual(config);
40+
});
41+
42+
it("keeps the whole palette a theme wrote", async () => {
43+
const config = edited((c) => {
44+
for (let i = 0; i < 16; i++) {
45+
(c.colors as unknown as Record<string, string>)[`color${i}`] =
46+
`#${i.toString(16).repeat(6)}`;
47+
}
48+
});
49+
50+
expect((await roundTrip(config)).colors).toEqual(config.colors);
51+
});
52+
53+
it("puts the config in the fragment, where a server never sees it", async () => {
54+
const link =
55+
(await sharing.toLink(
56+
edited((c) => {
57+
c.fonts.font_size = 20;
58+
}),
59+
BASE,
60+
)) ?? "";
61+
62+
const url = new URL(link);
63+
expect(url.search).toBe("");
64+
expect(url.pathname).toBe("/");
65+
expect(url.hash.startsWith("#c=")).toBe(true);
66+
});
67+
68+
it("keeps a link short enough to paste", async () => {
69+
// Everything a theme touches plus a page of settings, which is the
70+
// heaviest thing anybody shares in practice.
71+
const config = edited((c) => {
72+
for (let i = 0; i < 16; i++) {
73+
(c.colors as unknown as Record<string, string>)[`color${i}`] =
74+
`#a${i.toString(16)}b1c2`;
75+
}
76+
c.colors.background = "#101010";
77+
c.colors.foreground = "#e0e0e0";
78+
c.fonts.font_family = "JetBrains Mono";
79+
c.fonts.font_size = 13;
80+
c.scrollback.scrollback_lines = 20000;
81+
c.keyboard_shortcuts = Array.from({ length: 10 }, (_, i) => ({
82+
chord: `ctrl+shift+f${i}`,
83+
action: `goto_tab ${i}`,
84+
}));
85+
});
86+
87+
const link = (await sharing.toLink(config, BASE)) ?? "";
88+
expect(link.length).toBeLessThan(2000);
89+
});
90+
91+
it("carries only what was changed", async () => {
92+
const untouched = (await sharing.toLink(DEFAULT_KITTY_CONFIG, BASE)) ?? "";
93+
const oneField =
94+
(await sharing.toLink(
95+
edited((c) => {
96+
c.fonts.font_size = 20;
97+
}),
98+
BASE,
99+
)) ?? "";
100+
101+
expect(untouched.length).toBeLessThan(oneField.length);
102+
expect(await sharing.fromHash(new URL(untouched).hash)).toEqual(
103+
DEFAULT_KITTY_CONFIG,
104+
);
105+
});
106+
107+
it.each([
108+
["no fragment at all", ""],
109+
["a fragment about something else", "#section=fonts"],
110+
["an empty payload", "#c="],
111+
["a payload that is not base64", "#c=1!!!!not base64!!!!"],
112+
["a payload cut in half", "#c=1eJyrVkrLz1eyUkq"],
113+
["a marker nobody wrote", "#c=9abcdef"],
114+
["base64 that is not a config", `#c=0${btoa("hello there")}`],
115+
["base64 of JSON that is not one", `#c=0${btoa('["a","b"]')}`],
116+
])("returns nothing for %s", async (_label, hash) => {
117+
expect(await sharing.fromHash(hash)).toBeNull();
118+
});
119+
120+
it("sanitises a link the way it sanitises an imported file", async () => {
121+
// A link is written by somebody else, so it gets no more trust than a file.
122+
const hostile = btoa(
123+
JSON.stringify({
124+
__proto__: { polluted: "yes" },
125+
fonts: { font_size: "enormous", not_an_option: 1 },
126+
}),
127+
);
128+
129+
const restored = (await sharing.fromHash(`#c=0${hostile}`)) as unknown as {
130+
fonts: Record<string, unknown>;
131+
};
132+
133+
expect(({} as Record<string, unknown>)["polluted"]).toBeUndefined();
134+
expect(typeof restored.fonts["font_size"]).toBe("number");
135+
expect(restored.fonts["not_an_option"]).toBeUndefined();
136+
});
137+
138+
it("refuses a fragment far larger than any config", async () => {
139+
expect(await sharing.fromHash(`#c=0${"A".repeat(30_000)}`)).toBeNull();
140+
});
141+
});

0 commit comments

Comments
 (0)