| 1 | import { |
| 2 | isModifierPairShortcut, |
| 3 | type DesktopCaptureConfigApplied, |
| 4 | type DesktopCaptureConfigPreview, |
| 5 | type DesktopSnapShotState, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import { parseKeybindingShortcut } from "@t3tools/shared/keybindings"; |
| 8 | import { FileDiff } from "@pierre/diffs/react"; |
| 9 | import { parseDiffFromFile } from "@pierre/diffs"; |
| 10 | import { useMemo, useState } from "react"; |
| 11 | import { getDesktopSnapShotBridge } from "../../lib/desktopSnapShot"; |
| 12 | import { resolveDiffThemeName } from "../../lib/diffRendering"; |
| 13 | import { useTheme } from "../../hooks/useTheme"; |
| 14 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 15 | import { Button } from "../ui/button"; |
| 16 | import { toastManager } from "../ui/toast"; |
| 17 | import { shortcutToKeybindingInput } from "./KeybindingsSettings.logic"; |
| 18 | import { useSnapShotShortcutRecorder } from "./useSnapShotShortcutRecorder"; |
| 19 | |
| 20 | const DEFAULT_SHORTCUT = parseKeybindingShortcut("Ctrl+Shift+2")!; |
| 21 | |
| 22 | /** Wizard-owned config review; config contents never leave the desktop bridge. */ |
| 23 | export 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 | } |