346 lines · 12.2 KB · TypeScript
RawDownload
import {  isModifierPairShortcut,  type DesktopCaptureConfigApplied,  type DesktopCaptureConfigPreview,  type DesktopSnapShotState,} from "@t3tools/contracts";import { parseKeybindingShortcut } from "@t3tools/shared/keybindings";import { FileDiff } from "@pierre/diffs/react";import { parseDiffFromFile } from "@pierre/diffs";import { useMemo, useState } from "react";import { getDesktopSnapShotBridge } from "../../lib/desktopSnapShot";import { resolveDiffThemeName } from "../../lib/diffRendering";import { useTheme } from "../../hooks/useTheme";import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";import { Button } from "../ui/button";import { toastManager } from "../ui/toast";import { shortcutToKeybindingInput } from "./KeybindingsSettings.logic";import { useSnapShotShortcutRecorder } from "./useSnapShotShortcutRecorder"; const DEFAULT_SHORTCUT = parseKeybindingShortcut("Ctrl+Shift+2")!; /** Wizard-owned config review; config contents never leave the desktop bridge. */export function CaptureShortcutConfig({  state,  disabled = false,  onBusyChange,  onSaved,  onComplete,}: {  state: DesktopSnapShotState;  disabled?: boolean;  onBusyChange?: (busy: boolean) => void;  onSaved?: () => Promise<unknown>;  onComplete?: () => Promise<void>;}) {  const bridge = getDesktopSnapShotBridge();  const { resolvedTheme } = useTheme();  const { copyToClipboard, isCopied } = useCopyToClipboard();  const [preview, setPreview] = useState<DesktopCaptureConfigPreview | null>(null);  const [result, setResult] = useState<DesktopCaptureConfigApplied | null>(null);  const [error, setError] = useState<{ message: string; detail?: string } | null>(null);  const [working, setWorking] = useState<"reading" | "writing" | null>(null);  const [keys, setKeys] = useState<string | null>(null);  const [customFile, setCustomFile] = useState(false);  const busy = disabled || working !== null;  const supported = Boolean(bridge?.previewSnapShotConfig && bridge.applySnapShotConfig);  const changed = preview !== null && preview.before !== preview.after;  const niri = state.linuxBackend === "niri";  const desktop = niri ? "Niri" : "Hyprland";  const shortcutKeys = (keys ?? preview?.shortcut)?.trim();  const recorder = useSnapShotShortcutRecorder({    shortcut: shortcutKeys      ? (parseKeybindingShortcut(shortcutKeys.replace(/super/gi, "meta")) ?? DEFAULT_SHORTCUT)      : DEFAULT_SHORTCUT,    disabled: busy,    allowModifierPairs: false,    onStart: () => setError(null),    onError: (message) => setError({ message }),    onRecord: (shortcut) => {      if (isModifierPairShortcut(shortcut)) return;      setKeys(        shortcutToKeybindingInput({          ...shortcut,          ctrlKey: shortcut.ctrlKey || shortcut.modKey,          modKey: false,        }),      );      setPreview(null);      setError(null);    },  });  const actionBusy = busy || recorder.recording;  const diff = useMemo(    () =>      preview && changed        ? parseDiffFromFile(            { name: preview.path, contents: preview.before },            { name: preview.path, contents: preview.after },          )        : null,    [preview, changed],  );  const begin = (phase: "reading" | "writing") => {    setWorking(phase);    onBusyChange?.(true);    setError(null);  };  const end = () => {    setWorking(null);    onBusyChange?.(false);  };  const read = async (chooseFile = customFile, operation: "install" | "remove" = "install") => {    if (actionBusy || !bridge?.previewSnapShotConfig) return;    begin("reading");    setPreview(null);    setResult(null);    setCustomFile(chooseFile);    try {      setPreview(        await bridge.previewSnapShotConfig({          operation,          chooseFile,          ...(keys?.trim() ? { shortcut: keys.trim() } : {}),        }),      );    } catch (cause) {      setError({        message: "Couldn't prepare the changes. Check Advanced for help.",        ...(cause instanceof Error ? { detail: cause.message } : {}),      });    } finally {      end();    }  };  const apply = async () => {    if (actionBusy || !preview || !bridge?.applySnapShotConfig) return;    begin("writing");    try {      const applied = await bridge.applySnapShotConfig(preview.id);      setResult(applied);      await onSaved?.();      if (!applied.warning && preview.operation === "install" && onComplete) {        toastManager.add({          type: "success",          title: "Shortcut saved",          description: `Use ${preview.shortcut} from another app.`,        });        await onComplete();      }    } catch (cause) {      setError({        message: "Couldn't save your shortcut. Review the changes and try again.",        ...(cause instanceof Error ? { detail: cause.message } : {}),      });      setPreview(null);    } finally {      end();    }  };   return (    <div className="space-y-4 text-sm">      {!result ? (        <div className="flex items-center justify-between gap-3">          <span>Shortcut</span>          {recorder.input}        </div>      ) : null}      {recorder.recording ? (        <p role="status" className="text-xs text-muted-foreground">          Press your shortcut. Esc cancels.        </p>      ) : null}      {result ? (        <p role="status">          {result.warning            ? "Saved, but the shortcut needs attention. Check Advanced for help."            : preview?.operation === "remove"              ? "Shortcut removed."              : `Use ${preview?.shortcut} from another app to capture a window.`}        </p>      ) : preview ? (        <>          <p className="text-muted-foreground">            {changed              ? preview.operation === "remove"                ? "Review the change below to remove your shortcut."                : "Review the change below, then save your shortcut."              : preview.operation === "remove"                ? "There's no capture shortcut to remove."                : "This shortcut is already set up."}          </p>          {diff ? (            <div              className="max-h-80 overflow-auto rounded-lg border text-xs"              aria-label="Shortcut changes"            >              <FileDiff                fileDiff={diff}                options={{                  diffStyle: "unified",                  theme: resolveDiffThemeName(resolvedTheme),                  overflow: "wrap",                }}              />            </div>          ) : null}          {changed ? (            <p className="text-xs text-muted-foreground">              Only these changes will be saved. We'll keep a backup.            </p>          ) : null}          <div className="flex gap-2">            {changed || preview.operation === "install" ? (              <Button                disabled={                  actionBusy ||                  (preview.operation === "install" && state.shortcutActionRegistered === false)                }                aria-busy={working === "writing"}                onClick={() => void apply()}              >                {working === "writing"                  ? "Saving…"                  : changed                    ? preview.operation === "install"                      ? "Save shortcut"                      : "Remove shortcut"                    : "Done"}              </Button>            ) : null}            <Button variant="ghost" disabled={actionBusy} onClick={() => setPreview(null)}>              Cancel            </Button>          </div>        </>      ) : (        <>          <p className="text-muted-foreground">            Allow T3 Code to read your desktop settings. You'll review any changes here before            saving.          </p>          <Button            disabled={actionBusy || !supported}            aria-busy={working === "reading"}            onClick={() => void read()}          >            {working === "reading" ? "Preparing changes…" : "Review changes"}          </Button>          {!supported ? (            <p className="text-xs text-muted-foreground">              Update T3 Code to finish setting up your shortcut.            </p>          ) : null}        </>      )}      {error ? (        <p role="alert" className="text-destructive">          {error.message}        </p>      ) : null}      {state.shortcutActionRegistered === false && state.shortcutMessage ? (        <p role="status" className="text-muted-foreground">          {state.shortcutPending            ? "Connecting to your desktop…"            : "Restart T3 Code to finish connecting your shortcut."}        </p>      ) : null}      <details className="text-xs text-muted-foreground">        <summary className="cursor-pointer">Advanced</summary>        <div className="mt-3 space-y-3">          {error?.detail || result?.warning ? (            <div className="space-y-1">              <p className="font-medium text-foreground">Troubleshooting</p>              <p className="break-words">{error?.detail ?? result?.warning}</p>            </div>          ) : null}          <div className="space-y-1">            <p className="font-medium text-foreground">Settings file</p>            <p className="break-all font-mono">              {preview?.path ??                state.shortcutConfigPath ??                (niri ? "~/.config/niri/config.kdl" : "~/.config/hypr/hyprland.conf")}            </p>            {niri ? <p>T3 Code also reads any files included by this file.</p> : null}            {preview && preview.resolvedPath !== preview.path ? (              <p className="break-all">Linked to {preview.resolvedPath}. The link will be kept.</p>            ) : null}          </div>          <div className="flex flex-wrap gap-2">            <Button              size="sm"              variant="outline"              disabled={actionBusy || !supported}              onClick={() => void read(true)}            >              Choose a different file…            </Button>            <Button              size="sm"              variant="ghost"              disabled={actionBusy || !supported}              onClick={() => void read(customFile, "remove")}            >              Remove shortcut…            </Button>            {result ? (              <Button                size="sm"                variant="outline"                disabled={actionBusy || !supported}                onClick={() => void read()}              >                Review changes              </Button>            ) : null}          </div>          <p>            Use your desktop's shortcut settings file.{" "}            {niri              ? "A custom --config or NIRI_CONFIG can change its location."              : "On Omarchy, use your own bindings file, not its defaults."}          </p>          {result?.backupPath ? <p className="break-all">Backup: {result.backupPath}</p> : null}          <p className="font-medium text-foreground">Manual setup</p>          <p>            {niri              ? "Paste this inside binds { … } in your Niri config, then save."              : "Add this binding to your Hyprland config, then save."}{" "}            Change the keys if needed.          </p>          <pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all rounded-xl bg-muted/50 p-3">            {state.shortcutBinding}          </pre>          <Button            size="sm"            variant="outline"            disabled={actionBusy || !state.shortcutBinding}            onClick={() => {              if (state.shortcutBinding) copyToClipboard(state.shortcutBinding);            }}          >            {isCopied ? "Copied" : "Copy shortcut"}          </Button>          <p>            Turn capture off in T3 Code to stop it. Remove the shortcut from {desktop} to free up            the keys.          </p>          {state.shortcutActionRegistered === false ? (            <p role="status">{state.shortcutMessage}</p>          ) : null}          {onComplete ? (            <Button              size="sm"              variant="outline"              disabled={actionBusy || state.shortcutActionRegistered === false}              onClick={() => void onComplete()}            >              I've added the shortcut            </Button>          ) : null}        </div>      </details>    </div>  );}