1import {
2 isModifierPairShortcut,
3 type DesktopCaptureConfigApplied,
4 type DesktopCaptureConfigPreview,
5 type DesktopSnapShotState,
6} from "@t3tools/contracts";
7import { parseKeybindingShortcut } from "@t3tools/shared/keybindings";
8import { FileDiff } from "@pierre/diffs/react";
9import { parseDiffFromFile } from "@pierre/diffs";
10import { useMemo, useState } from "react";
11import { getDesktopSnapShotBridge } from "../../lib/desktopSnapShot";
12import { resolveDiffThemeName } from "../../lib/diffRendering";
13import { useTheme } from "../../hooks/useTheme";
14import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
15import { Button } from "../ui/button";
16import { toastManager } from "../ui/toast";
17import { shortcutToKeybindingInput } from "./KeybindingsSettings.logic";
18import { useSnapShotShortcutRecorder } from "./useSnapShotShortcutRecorder";
19
20const DEFAULT_SHORTCUT = parseKeybindingShortcut("Ctrl+Shift+2")!;
21
22/** Wizard-owned config review; config contents never leave the desktop bridge. */
23export function CaptureShortcutConfig({
24 state,
25 disabled = false,
26 onBusyChange,
27 onSaved,
28 onComplete,
29}: {
30 state: DesktopSnapShotState;
31 disabled?: boolean;
32 onBusyChange?: (busy: boolean) => void;
33 onSaved?: () => Promise<unknown>;
34 onComplete?: () => Promise<void>;
35}) {
36 const bridge = getDesktopSnapShotBridge();
37 const { resolvedTheme } = useTheme();
38 const { copyToClipboard, isCopied } = useCopyToClipboard();
39 const [preview, setPreview] = useState<DesktopCaptureConfigPreview | null>(null);
40 const [result, setResult] = useState<DesktopCaptureConfigApplied | null>(null);
41 const [error, setError] = useState<{ message: string; detail?: string } | null>(null);
42 const [working, setWorking] = useState<"reading" | "writing" | null>(null);
43 const [keys, setKeys] = useState<string | null>(null);
44 const [customFile, setCustomFile] = useState(false);
45 const busy = disabled || working !== null;
46 const supported = Boolean(bridge?.previewSnapShotConfig && bridge.applySnapShotConfig);
47 const changed = preview !== null && preview.before !== preview.after;
48 const niri = state.linuxBackend === "niri";
49 const desktop = niri ? "Niri" : "Hyprland";
50 const shortcutKeys = (keys ?? preview?.shortcut)?.trim();
51 const recorder = useSnapShotShortcutRecorder({
52 shortcut: shortcutKeys
53 ? (parseKeybindingShortcut(shortcutKeys.replace(/super/gi, "meta")) ?? DEFAULT_SHORTCUT)
54 : DEFAULT_SHORTCUT,
55 disabled: busy,
56 allowModifierPairs: false,
57 onStart: () => setError(null),
58 onError: (message) => setError({ message }),
59 onRecord: (shortcut) => {
60 if (isModifierPairShortcut(shortcut)) return;
61 setKeys(
62 shortcutToKeybindingInput({
63 ...shortcut,
64 ctrlKey: shortcut.ctrlKey || shortcut.modKey,
65 modKey: false,
66 }),
67 );
68 setPreview(null);
69 setError(null);
70 },
71 });
72 const actionBusy = busy || recorder.recording;
73 const diff = useMemo(
74 () =>
75 preview && changed
76 ? parseDiffFromFile(
77 { name: preview.path, contents: preview.before },
78 { name: preview.path, contents: preview.after },
79 )
80 : null,
81 [preview, changed],
82 );
83 const begin = (phase: "reading" | "writing") => {
84 setWorking(phase);
85 onBusyChange?.(true);
86 setError(null);
87 };
88 const end = () => {
89 setWorking(null);
90 onBusyChange?.(false);
91 };
92 const read = async (chooseFile = customFile, operation: "install" | "remove" = "install") => {
93 if (actionBusy || !bridge?.previewSnapShotConfig) return;
94 begin("reading");
95 setPreview(null);
96 setResult(null);
97 setCustomFile(chooseFile);
98 try {
99 setPreview(
100 await bridge.previewSnapShotConfig({
101 operation,
102 chooseFile,
103 ...(keys?.trim() ? { shortcut: keys.trim() } : {}),
104 }),
105 );
106 } catch (cause) {
107 setError({
108 message: "Couldn't prepare the changes. Check Advanced for help.",
109 ...(cause instanceof Error ? { detail: cause.message } : {}),
110 });
111 } finally {
112 end();
113 }
114 };
115 const apply = async () => {
116 if (actionBusy || !preview || !bridge?.applySnapShotConfig) return;
117 begin("writing");
118 try {
119 const applied = await bridge.applySnapShotConfig(preview.id);
120 setResult(applied);
121 await onSaved?.();
122 if (!applied.warning && preview.operation === "install" && onComplete) {
123 toastManager.add({
124 type: "success",
125 title: "Shortcut saved",
126 description: `Use ${preview.shortcut} from another app.`,
127 });
128 await onComplete();
129 }
130 } catch (cause) {
131 setError({
132 message: "Couldn't save your shortcut. Review the changes and try again.",
133 ...(cause instanceof Error ? { detail: cause.message } : {}),
134 });
135 setPreview(null);
136 } finally {
137 end();
138 }
139 };
140
141 return (
142 <div className="space-y-4 text-sm">
143 {!result ? (
144 <div className="flex items-center justify-between gap-3">
145 <span>Shortcut</span>
146 {recorder.input}
147 </div>
148 ) : null}
149 {recorder.recording ? (
150 <p role="status" className="text-xs text-muted-foreground">
151 Press your shortcut. Esc cancels.
152 </p>
153 ) : null}
154 {result ? (
155 <p role="status">
156 {result.warning
157 ? "Saved, but the shortcut needs attention. Check Advanced for help."
158 : preview?.operation === "remove"
159 ? "Shortcut removed."
160 : `Use ${preview?.shortcut} from another app to capture a window.`}
161 </p>
162 ) : preview ? (
163 <>
164 <p className="text-muted-foreground">
165 {changed
166 ? preview.operation === "remove"
167 ? "Review the change below to remove your shortcut."
168 : "Review the change below, then save your shortcut."
169 : preview.operation === "remove"
170 ? "There's no capture shortcut to remove."
171 : "This shortcut is already set up."}
172 </p>
173 {diff ? (
174 <div
175 className="max-h-80 overflow-auto rounded-lg border text-xs"
176 aria-label="Shortcut changes"
177 >
178 <FileDiff
179 fileDiff={diff}
180 options={{
181 diffStyle: "unified",
182 theme: resolveDiffThemeName(resolvedTheme),
183 overflow: "wrap",
184 }}
185 />
186 </div>
187 ) : null}
188 {changed ? (
189 <p className="text-xs text-muted-foreground">
190 Only these changes will be saved. We'll keep a backup.
191 </p>
192 ) : null}
193 <div className="flex gap-2">
194 {changed || preview.operation === "install" ? (
195 <Button
196 disabled={
197 actionBusy ||
198 (preview.operation === "install" && state.shortcutActionRegistered === false)
199 }
200 aria-busy={working === "writing"}
201 onClick={() => void apply()}
202 >
203 {working === "writing"
204 ? "Saving…"
205 : changed
206 ? preview.operation === "install"
207 ? "Save shortcut"
208 : "Remove shortcut"
209 : "Done"}
210 </Button>
211 ) : null}
212 <Button variant="ghost" disabled={actionBusy} onClick={() => setPreview(null)}>
213 Cancel
214 </Button>
215 </div>
216 </>
217 ) : (
218 <>
219 <p className="text-muted-foreground">
220 Allow The Knife to read your desktop settings. You'll review any changes here before
221 saving.
222 </p>
223 <Button
224 disabled={actionBusy || !supported}
225 aria-busy={working === "reading"}
226 onClick={() => void read()}
227 >
228 {working === "reading" ? "Preparing changes…" : "Review changes"}
229 </Button>
230 {!supported ? (
231 <p className="text-xs text-muted-foreground">
232 Update The Knife to finish setting up your shortcut.
233 </p>
234 ) : null}
235 </>
236 )}
237 {error ? (
238 <p role="alert" className="text-destructive">
239 {error.message}
240 </p>
241 ) : null}
242 {state.shortcutActionRegistered === false && state.shortcutMessage ? (
243 <p role="status" className="text-muted-foreground">
244 {state.shortcutPending
245 ? "Connecting to your desktop…"
246 : "Restart The Knife to finish connecting your shortcut."}
247 </p>
248 ) : null}
249 <details className="text-xs text-muted-foreground">
250 <summary className="cursor-pointer">Advanced</summary>
251 <div className="mt-3 space-y-3">
252 {error?.detail || result?.warning ? (
253 <div className="space-y-1">
254 <p className="font-medium text-foreground">Troubleshooting</p>
255 <p className="break-words">{error?.detail ?? result?.warning}</p>
256 </div>
257 ) : null}
258 <div className="space-y-1">
259 <p className="font-medium text-foreground">Settings file</p>
260 <p className="break-all font-mono">
261 {preview?.path ??
262 state.shortcutConfigPath ??
263 (niri ? "~/.config/niri/config.kdl" : "~/.config/hypr/hyprland.conf")}
264 </p>
265 {niri ? <p>The Knife also reads any files included by this file.</p> : null}
266 {preview && preview.resolvedPath !== preview.path ? (
267 <p className="break-all">Linked to {preview.resolvedPath}. The link will be kept.</p>
268 ) : null}
269 </div>
270 <div className="flex flex-wrap gap-2">
271 <Button
272 size="sm"
273 variant="outline"
274 disabled={actionBusy || !supported}
275 onClick={() => void read(true)}
276 >
277 Choose a different file…
278 </Button>
279 <Button
280 size="sm"
281 variant="ghost"
282 disabled={actionBusy || !supported}
283 onClick={() => void read(customFile, "remove")}
284 >
285 Remove shortcut…
286 </Button>
287 {result ? (
288 <Button
289 size="sm"
290 variant="outline"
291 disabled={actionBusy || !supported}
292 onClick={() => void read()}
293 >
294 Review changes
295 </Button>
296 ) : null}
297 </div>
298 <p>
299 Use your desktop's shortcut settings file.{" "}
300 {niri
301 ? "A custom --config or NIRI_CONFIG can change its location."
302 : "On Omarchy, use your own bindings file, not its defaults."}
303 </p>
304 {result?.backupPath ? <p className="break-all">Backup: {result.backupPath}</p> : null}
305 <p className="font-medium text-foreground">Manual setup</p>
306 <p>
307 {niri
308 ? "Paste this inside binds { … } in your Niri config, then save."
309 : "Add this binding to your Hyprland config, then save."}{" "}
310 Change the keys if needed.
311 </p>
312 <pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all rounded-xl bg-muted/50 p-3">
313 {state.shortcutBinding}
314 </pre>
315 <Button
316 size="sm"
317 variant="outline"
318 disabled={actionBusy || !state.shortcutBinding}
319 onClick={() => {
320 if (state.shortcutBinding) copyToClipboard(state.shortcutBinding);
321 }}
322 >
323 {isCopied ? "Copied" : "Copy shortcut"}
324 </Button>
325 <p>
326 Turn capture off in The Knife to stop it. Remove the shortcut from {desktop} to free up
327 the keys.
328 </p>
329 {state.shortcutActionRegistered === false ? (
330 <p role="status">{state.shortcutMessage}</p>
331 ) : null}
332 {onComplete ? (
333 <Button
334 size="sm"
335 variant="outline"
336 disabled={actionBusy || state.shortcutActionRegistered === false}
337 onClick={() => void onComplete()}
338 >
339 I've added the shortcut
340 </Button>
341 ) : null}
342 </div>
343 </details>
344 </div>
345 );
346}