Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/renderer/CommandImportDialog.tsx

tsx415 行查看原文
// Adapted from ZCode ExternalAgentImportDialog (Apache-2.0), limited to explicit command import.
import * as Dialog from "@radix-ui/react-dialog";
import { ActionHint } from "@zpi-dev/ui";
import { AlertCircle, Check, ChevronRight, LoaderCircle, Minus, RefreshCw, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import type {
  CommandImportCandidate,
  CommandImportDiscovery,
  CommandImportItemResult,
  CommandImportMode,
  CommandImportResult,
} from "../shared/command-import.ts";
import { SettingsSelect } from "./SettingsSelect.tsx";
import { unwrap } from "./unwrap.ts";
import { useDialogDismiss } from "./use-dialog-dismiss.ts";
import { userErrorMessage } from "./user-error.ts";
import "./command-import.css";

function SelectionBox({
  checked,
  label,
  onToggle,
}: {
  checked: boolean | "mixed";
  label: string;
  onToggle: () => void;
}) {
  return (
    <label className="command-import-checkbox">
      <input
        type="checkbox"
        className="sr-only"
        checked={checked === true}
        aria-checked={checked}
        aria-label={label}
        ref={(input) => {
          if (input) input.indeterminate = checked === "mixed";
        }}
        onChange={onToggle}
      />
      <span data-checked={checked !== false} aria-hidden="true">
        {checked === "mixed" ? <Minus size={14} /> : checked ? <Check size={14} /> : null}
      </span>
    </label>
  );
}
function selectionState(items: CommandImportCandidate[], selected: Set<string>): boolean | "mixed" {
  const count = items.filter((item) => selected.has(item.id)).length;
  return count === 0 ? false : count === items.length ? true : "mixed";
}
function resultLabel(item: CommandImportItemResult): string {
  if (item.status === "imported") {
    return item.method === "copy" ? "已导入(复制)" : "已导入";
  }
  if (item.status === "failed") return `失败:${item.error ? userErrorMessage(item.error) : "无法导入命令"}`;
  return `已跳过:${item.skipReason === "sameNameExists" ? "同名已存在" : "文件已存在"}`;
}

/** Mount only in response to the user's Import action; mounting starts an explicit discovery. */
export function CommandImportDialog({
  onClose,
  onImported,
  projectId,
}: {
  projectId?: string;
  onClose: () => void;
  onImported: () => Promise<void> | void;
}) {
  const [open, close] = useDialogDismiss(onClose);
  const [discovery, setDiscovery] = useState<CommandImportDiscovery>();
  const [selected, setSelected] = useState<Set<string>>(() => new Set());
  const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
  const [sourceScope, setSourceScope] = useState<"global" | "project">("global");
  const [mode, setMode] = useState<CommandImportMode>("symlink");
  const [loading, setLoading] = useState(true);
  const [importing, setImporting] = useState(false);
  const [result, setResult] = useState<CommandImportResult>();
  const [error, setError] = useState("");
  const scanSequence = useRef(0);
  const mounted = useRef(true);
  const importPending = useRef(false);
  const load = useCallback(async () => {
    const sequence = ++scanSequence.current;
    setLoading(true);
    setResult(undefined);
    setSourceScope("global");
    setError("");
    setSelected(new Set());
    setExpanded(new Set());
    try {
      const next = unwrap(await window.zpi.discoverCommandImports(projectId));
      if (mounted.current && scanSequence.current === sequence) setDiscovery(next);
    } catch (error) {
      if (mounted.current && scanSequence.current === sequence) {
        setError(userErrorMessage(error));
        setDiscovery(undefined);
      }
    } finally {
      if (mounted.current && scanSequence.current === sequence) setLoading(false);
    }
  }, [projectId]);
  useEffect(() => {
    mounted.current = true;
    void load();
    return () => {
      mounted.current = false;
      scanSequence.current++;
    };
  }, [load]);
  const sources =
    discovery?.sources
      .map((source) => ({ ...source, commands: source.commands.filter((item) => item.importable) }))
      .filter((source) => source.commands.length) ?? [];
  const candidates = sources.flatMap((source) => source.commands);
  const visibleSources = sources.filter((source) => source.scope === sourceScope);
  const visibleCandidates = visibleSources.flatMap((source) => source.commands);
  const allState = selectionState(visibleCandidates, selected);
  const select = (items: CommandImportCandidate[], checked: boolean) =>
    setSelected((current) => {
      const next = new Set(current);
      for (const item of items) {
        if (checked) next.add(item.id);
        else next.delete(item.id);
      }
      return next;
    });
  const startImport = async () => {
    if (!discovery || !selected.size || importPending.current) return;
    importPending.current = true;
    setImporting(true);
    setError("");
    try {
      const imported = unwrap(
        await window.zpi.importCommands(
          {
            scanId: discovery.scanId,
            mode,
            selections: candidates
              .filter((item) => selected.has(item.id))
              .map(({ id, revision }) => ({ id, revision })),
          },
          projectId,
        ),
      );
      if (!mounted.current) return;
      setResult(imported);
      try {
        await onImported();
      } catch {
        setError("导入已完成,但命令列表刷新失败。请重新打开命令设置。");
      }
    } catch (error) {
      setError(userErrorMessage(error));
    } finally {
      importPending.current = false;
      if (mounted.current) setImporting(false);
    }
  };
  return (
    <Dialog.Root open={open} onOpenChange={(next) => !next && !importing && close()}>
      <Dialog.Portal>
        <Dialog.Overlay className="modal-backdrop command-import-backdrop" />
        <Dialog.Content
          className="command-import-dialog"
          onEscapeKeyDown={(event) => {
            event.stopPropagation();
            if (importing) event.preventDefault();
          }}
          onPointerDownOutside={(event) => importing && event.preventDefault()}
        >
          <header className="command-import-header">
            <Dialog.Title>导入外部 Agent 命令</Dialog.Title>
            <Dialog.Description className="sr-only">
              选择外部 Agent 的命令,以软链或直接复制方式导入到所选命令目录。
            </Dialog.Description>
            {!result && !importing && (
              <div className="command-import-header-tools">
                {discovery?.projectAvailable ? (
                  <SettingsSelect<"global" | "project">
                    label="来源范围"
                    value={sourceScope}
                    onChange={setSourceScope}
                    options={[
                      { value: "global", label: "全局" },
                      { value: "project", label: "项目" },
                    ]}
                  />
                ) : (
                  <span>全局</span>
                )}
                <ActionHint appearance="control" label="重新扫描">
                  <button
                    type="button"
                    className="command-import-icon-button"
                    aria-label="重新扫描"
                    disabled={loading}
                    onClick={() => void load()}
                  >
                    <RefreshCw size={12} className={loading ? "command-import-spinner" : undefined} />
                  </button>
                </ActionHint>
              </div>
            )}
            <Dialog.Close className="command-import-icon-button" aria-label="关闭导入" disabled={importing}>
              <X size={14} />
            </Dialog.Close>
          </header>
          <div className="command-import-body">
            {error && (
              <div className="command-import-warning" role="alert">
                {error}
              </div>
            )}
            {loading ? (
              <div className="command-import-loading" role="status">
                <LoaderCircle size={16} className="command-import-spinner" />
                正在扫描可导入命令...
              </div>
            ) : importing ? (
              <div className="command-import-loading" role="status">
                <LoaderCircle size={16} className="command-import-spinner" />
                正在导入命令到 ZPI
              </div>
            ) : result ? (
              <div className="command-import-results">
                <div className="command-import-stats" role="status">
                  <span>
                    <strong>{result.importedCount}</strong> 已导入
                  </span>
                  <span>
                    <strong>{result.skippedCount}</strong> 已跳过
                  </span>
                  <span>
                    <strong>{result.failedCount}</strong> 失败
                  </span>
                </div>
                <h3>命令导入结果</h3>
                <div className="command-import-result-list">
                  {result.items.map((item) => (
                    <div key={item.id} className="command-import-result-row" title={item.sourcePath}>
                      <span
                        className="command-import-result-icon"
                        data-status={item.status}
                        role="img"
                        aria-label={resultLabel(item)}
                        title={resultLabel(item)}
                      >
                        {item.status === "imported" ? (
                          <Check size={9} />
                        ) : item.status === "skipped" ? (
                          <AlertCircle size={10} />
                        ) : (
                          <X size={9} />
                        )}
                      </span>
                      <strong>{item.name}</strong>
                      <span>{resultLabel(item)}</span>
                    </div>
                  ))}
                </div>
              </div>
            ) : visibleCandidates.length ? (
              <div className="command-import-selection">
                <div className="command-import-select-all">
                  <div>
                    <SelectionBox
                      checked={allState}
                      label={allState === true ? "取消全选" : "全选"}
                      onToggle={() => select(visibleCandidates, allState !== true)}
                    />
                    <span>{allState === true ? "取消全选" : "全选"}</span>
                  </div>
                  <span>
                    已选 {visibleCandidates.filter((item) => selected.has(item.id)).length}/
                    {visibleCandidates.length}
                  </span>
                </div>
                {visibleSources.map((source) => {
                  const state = selectionState(source.commands, selected);
                  const isExpanded = expanded.has(source.id);
                  return (
                    <div key={source.id} className="command-import-source">
                      <div className="command-import-source-row">
                        <SelectionBox
                          checked={state}
                          label={`${state === true ? "取消选择" : "选择"}${source.label}下全部命令`}
                          onToggle={() => select(source.commands, state !== true)}
                        />
                        <button
                          type="button"
                          className="command-import-source-toggle"
                          aria-expanded={isExpanded}
                          aria-label={`${isExpanded ? "折叠" : "展开"}${source.label}命令`}
                          onClick={() =>
                            setExpanded((current) => {
                              const next = new Set(current);
                              if (next.has(source.id)) next.delete(source.id);
                              else next.add(source.id);
                              return next;
                            })
                          }
                        >
                          <span className="command-import-source-info">
                            <strong>{source.label}</strong>
                            <code>{source.path}</code>
                            <span>{source.commands.length} 个命令</span>
                          </span>
                          <ChevronRight size={14} className={isExpanded ? "expanded" : ""} />
                        </button>
                      </div>
                      {isExpanded && (
                        <div className="command-import-source-items">
                          {source.commands.map((item) => (
                            <div key={item.id} className="command-import-item">
                              <SelectionBox
                                checked={selected.has(item.id)}
                                label={`选择 ${item.name}`}
                                onToggle={() => select([item], !selected.has(item.id))}
                              />
                              <button
                                type="button"
                                className="command-import-item-text"
                                title={item.sourcePath}
                                onClick={() => select([item], !selected.has(item.id))}
                              >
                                <span>
                                  <strong>{item.name}</strong>
                                  {item.argumentHint && <code>{item.argumentHint}</code>}
                                </span>
                                {item.description && <span>{item.description}</span>}
                              </button>
                            </div>
                          ))}
                        </div>
                      )}
                    </div>
                  );
                })}
              </div>
            ) : (
              <div className="command-import-empty">
                {candidates.length
                  ? "当前范围下没有可导入命令。"
                  : "暂无可导入命令。可检查外部 Agent 的 commands 目录后重新扫描。"}
              </div>
            )}
            {!loading && !result && discovery?.warnings.length ? (
              <details className="command-import-warnings">
                <summary>有 {discovery.warnings.length} 个文件或目录未能扫描</summary>
                {discovery.warnings.map((warning) => (
                  <p key={warning}>{warning}</p>
                ))}
              </details>
            ) : null}
          </div>
          <footer className="command-import-footer">
            {result ? (
              <button type="button" className="command-import-primary" onClick={close}>
                完成
              </button>
            ) : (
              <>
                <div className="command-import-footer-info">
                  <span>
                    {loading ? "正在扫描..." : `发现 ${candidates.length} 个可导入命令`}
                    {selected.size > 0 ? ` · 已选 ${selected.size}` : ""}
                  </span>
                  {!importing && (
                    <div className="command-import-mode">
                      <SettingsSelect<CommandImportMode>
                        label="导入方式"
                        value={mode}
                        onChange={setMode}
                        options={[
                          { value: "symlink", label: "软链" },
                          { value: "copy", label: "直接复制" },
                        ]}
                      />
                      <ActionHint
                        appearance="control"
                        label="导入方式说明"
                        className="command-import-mode-tooltip"
                        description={
                          mode === "symlink"
                            ? "创建指向外部 Agent 命令文件的链接,跟随来源后续变更。来源路径须持续可用;编辑会在确认后修改来源文件。"
                            : "将命令文件复制到 ZPI。外部 Agent 文件后续变更不会自动同步。"
                        }
                      >
                        <button type="button" className="command-import-help" aria-label="导入方式说明">
                          <AlertCircle size={12} />
                        </button>
                      </ActionHint>
                    </div>
                  )}
                </div>
                {!importing && (
                  <button
                    type="button"
                    className="command-import-primary"
                    disabled={loading || !selected.size}
                    title={discovery?.targetDirectory ?? "~/.zpi/commands"}
                    onClick={() => void startImport()}
                  >
                    {discovery?.targetScope === "project" ? "导入到项目" : "导入到全局"}
                  </button>
                )}
              </>
            )}
          </footer>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}