Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/renderer/TaskSearchDialog.tsx

tsx481 行查看原文
// Ported from ZCode command-center/CommandCenterDialog and quickPickStyles (Apache-2.0).
import * as Dialog from "@radix-ui/react-dialog";
import { FileIcon } from "@zpi-dev/ui";
import { Command } from "cmdk";
import { ChevronDown, File, List, MessageSquare, MessagesSquare, Rocket, Search } from "lucide-react";
import { type ComponentType, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import type { ProjectRecord, SessionRecord, TaskSearchChange, TaskSearchResult } from "../shared/bridge.ts";
import type { TaskSearchSnippet } from "../shared/task-search.ts";
import { FileSearchStatus } from "./FileSearchStatus.tsx";
import { HighlightedMatchText } from "./HighlightedMatchText.tsx";
import { formatTaskRelativeTime } from "./sidebar-task-model.ts";
import { report, useStore } from "./store.ts";
import {
  type CommandCenterSearchHistoryEntry,
  type CommandCenterSearchScope,
  clearCommandCenterSearchHistory,
  pushCommandCenterSearchHistory,
  readCommandCenterSearchHistory,
} from "./task-search-history.ts";
import { Trash2 } from "./task-search-icons.ts";
import { resolveSearchScope, searchScopePrefix, taskSearchRows } from "./task-search-model.ts";
import { unwrap } from "./unwrap.ts";
import { useDialogDismiss } from "./use-dialog-dismiss.ts";
import { useOverlayShortcut } from "./use-overlay-shortcut.ts";
import { useWorkspaceFileSearch } from "./use-workspace-file-search.ts";
import { userErrorMessage } from "./user-error.ts";

export interface SearchCommand {
  id: string;
  title: string;
  section: "建议" | "面板" | "配置";
  keywords: string;
  icon: ComponentType<{ size?: number; className?: string }>;
  shortcut?: string;
  disabled?: boolean;
  run: () => unknown;
}
const scopes = [
  ["all", "全部", List],
  ["commands", "操作", Rocket],
  ["conversations", "任务", MessagesSquare],
  ["files", "文件", File],
] as const;
function Section({ title, children }: { title: string; children: ReactNode }) {
  return (
    <Command.Group heading={title} className="task-search-group">
      {children}
    </Command.Group>
  );
}
export function TaskSearchDialog({
  selected,
  workspaceKey,
  projects,
  commands,
  onClose: onClosed,
  onSelectTask,
  onOpenFile,
}: {
  selected?: string;
  workspaceKey: string;
  projects: ProjectRecord[];
  commands: SearchCommand[];
  onClose: () => void;
  onSelectTask: (task: SessionRecord, query?: string, snippet?: TaskSearchSnippet) => Promise<void>;
  onOpenFile: (path: string) => Promise<unknown>;
}) {
  const [open, close] = useDialogDismiss(onClosed);
  useOverlayShortcut("search", open, close);
  const input = useRef<HTMLInputElement>(null);
  const opener = useRef(document.activeElement);
  const composing = useRef(false);
  const [raw, setRaw] = useState("");
  const [manualScope, setManualScope] = useState<CommandCenterSearchScope>("all");
  const resolved = resolveSearchScope(raw);
  const scope = resolved.scope ?? manualScope;
  const query = resolved.query;
  const [expanded, setExpanded] = useState<Set<"conversations" | "files">>(() => new Set());
  const [historyExpanded, setHistoryExpanded] = useState(false);
  const [history, setHistory] = useState(() => readCommandCenterSearchHistory(workspaceKey));
  const [result, setResult] = useState<{ query: string; tasks: TaskSearchResult[] }>();
  const [taskError, setTaskError] = useState("");
  const [changes, setChanges] = useState<TaskSearchChange[]>([]);
  const actionSelected = useRef(false);
  const searchingTasks = scope === "all" || scope === "conversations";
  const searchingFiles = Boolean(query) && (scope === "all" || scope === "files");
  const fileSearch = useWorkspaceFileSearch({
    sessionId: selected ?? null,
    query,
    enabled: open && searchingFiles,
    filesOnly: true,
    limit: 80,
  });
  const fileError = fileSearch.error;
  const taskRevision = useStore((state) =>
    [...state.sessions.values()]
      .filter((task) => !task.draft && !task.parentSessionId && task.archivedAt == null)
      .map((task) => `${task.id}:${task.title}:${task.status}`)
      .join("\n"),
  );
  useEffect(() => {
    setExpanded(new Set());
  }, [query, scope]);
  useEffect(() => {
    setTaskError("");
    if (!open || !searchingTasks) return;
    let cancelled = false;
    const timer = setTimeout(
      () =>
        void window.zpi
          .searchTasks(query, !query && workspaceKey !== "ZPI" ? workspaceKey : null)
          .then(unwrap)
          .then((tasks) => {
            if (!cancelled) setResult({ query, tasks });
          })
          .catch((error) => {
            if (!cancelled) setTaskError(userErrorMessage(error));
          }),
      query ? 200 : 0,
    );
    return () => {
      cancelled = true;
      clearTimeout(timer);
    };
  }, [open, query, searchingTasks, workspaceKey, taskRevision]);
  useEffect(() => {
    if (!open || !selected) return;
    let cancelled = false;
    void window.zpi
      .getTaskSearchChanges(selected)
      .then(unwrap)
      .then((items) => {
        if (!cancelled) setChanges(items);
      })
      .catch(() => {});
    return () => {
      cancelled = true;
    };
  }, [open, selected]);
  const tasks = result?.query === query ? result.tasks : [];
  const loadingTasks = searchingTasks && result?.query !== query && !taskError;
  const rows = useMemo(() => taskSearchRows(tasks, query), [tasks, query]);
  const fileRows = fileSearch.result?.entries ?? [];
  const commandRows = commands.filter((command) =>
    query
      .trim()
      .toLocaleLowerCase()
      .split(/\s+/)
      .filter(Boolean)
      .every((part) => `${command.title} ${command.keywords}`.toLocaleLowerCase().includes(part)),
  );
  const run = (action: () => unknown, rememberScope?: CommandCenterSearchScope) => {
    if (query && rememberScope)
      setHistory(pushCommandCenterSearchHistory({ workspaceKey, query, scope: rememberScope }));
    actionSelected.current = true;
    close();
    void Promise.resolve().then(action).catch(report);
  };
  const recent = tasks.filter(
    (task) =>
      task.id !== selected &&
      (workspaceKey === "ZPI" ? task.projectId === null : (task.cwd ?? "") === workspaceKey),
  );
  const recentRows = scope === "conversations" ? recent : recent.slice(0, 3);
  const visibleRows = scope === "conversations" || expanded.has("conversations") ? rows : rows.slice(0, 3);
  const visibleFiles = scope === "files" || expanded.has("files") ? fileRows : fileRows.slice(0, 3);
  const hasResults =
    ((scope === "all" || scope === "commands") && commandRows.length > 0) ||
    (searchingTasks && rows.length > 0) ||
    (searchingFiles && (fileRows.length > 0 || Boolean(fileSearch.result?.continuation)));
  const fileLoading = fileSearch.loading;
  const more = (section: "conversations" | "files", count: number) =>
    !expanded.has(section) && count > 3 && scope === "all" ? (
      <button
        type="button"
        className="task-search-more"
        onClick={() => setExpanded((current) => new Set(current).add(section))}
      >
        查看更多结果
        <ChevronDown size={14} />
      </button>
    ) : null;
  const taskItem = (task: SessionRecord, key = task.id, snippet?: TaskSearchSnippet) => {
    const label =
      projects.find((project) => project.id === task.projectId)?.name ??
      task.cwd?.replace(/\\/g, "/").split("/").filter(Boolean).at(-1) ??
      "ZPI";
    return (
      <Command.Item
        key={key}
        value={key}
        className="task-search-item"
        onSelect={() =>
          run(() => onSelectTask(task, query || undefined, snippet), query ? "conversations" : undefined)
        }
      >
        <MessageSquare size={14} className="task-search-icon" />
        <span className={query ? "task-search-task-text" : "task-search-title"}>
          <span className="task-search-title">
            <HighlightedMatchText text={task.title.trim() || "未命名任务"} query={query} />
          </span>
          {snippet && (
            <span className="task-search-snippet">
              <HighlightedMatchText text={snippet.text} query={query} />
            </span>
          )}
        </span>
        {query && <span className="task-search-metadata">{label}</span>}
        <span className={query ? "task-search-timestamp" : "task-search-metadata"}>
          {formatTaskRelativeTime(task.updatedAt)}
        </span>
      </Command.Item>
    );
  };
  return (
    <Dialog.Root
      open={open}
      onOpenChange={(value) => {
        if (!value) close();
      }}
    >
      <Dialog.Portal>
        <Dialog.Overlay className="modal-backdrop" />
        <Dialog.Content
          className="task-search-dialog"
          data-shortcut-owner="search"
          aria-describedby={undefined}
          onOpenAutoFocus={(event) => {
            event.preventDefault();
            input.current?.focus();
          }}
          onCloseAutoFocus={(event) => {
            event.preventDefault();
            if (actionSelected.current) {
              // Focus the selected hit after Radix releases the exiting dialog's focus scope.
              document.querySelector<HTMLInputElement>(".task-find-bar input")?.focus();
              return;
            }
            if (opener.current instanceof HTMLElement && opener.current.isConnected) opener.current.focus();
          }}
          onEscapeKeyDown={(event) => {
            if (composing.current || event.isComposing || event.keyCode === 229) event.preventDefault();
          }}
        >
          <Dialog.Title className="task-search-sr-only">搜索任务</Dialog.Title>
          <Command
            shouldFilter={false}
            loop
            className="task-search-command"
            onKeyDownCapture={(event) => {
              if (composing.current || event.nativeEvent.isComposing || event.keyCode === 229) {
                if (event.key === "Enter" || event.key === "Escape") {
                  event.preventDefault();
                  event.stopPropagation();
                }
              }
            }}
            onKeyDown={(event) => {
              if (event.nativeEvent.isComposing || event.keyCode === 229) {
                event.stopPropagation();
              }
            }}
          >
            <div className="task-search-header">
              <div className="task-search-input-wrap">
                <Search size={16} />
                <Command.Input
                  ref={input}
                  onCompositionStart={() => {
                    composing.current = true;
                  }}
                  onCompositionEnd={() => {
                    composing.current = false;
                  }}
                  value={raw}
                  onValueChange={setRaw}
                  placeholder="搜索操作、任务或文件"
                  aria-label="搜索操作、任务或文件"
                />
              </div>
              <div role="tablist" aria-label="命令中心范围" className="task-search-scopes">
                {scopes.map(([value, label, Icon]) => (
                  <button
                    type="button"
                    role="tab"
                    aria-selected={scope === value}
                    key={value}
                    onClick={() => {
                      setManualScope(value);
                      if (resolved.explicit) setRaw(query);
                      input.current?.focus();
                    }}
                  >
                    <Icon size={12} />
                    {label}
                  </button>
                ))}
              </div>
            </div>
            <Command.List className="task-search-list">
              {!query &&
                (scope === "all" || scope === "files") &&
                (changes.length > 0 || scope === "files") && (
                  <Section title="最近变更">
                    {changes.length ? (
                      changes.slice(0, scope === "files" ? undefined : 3).map((file) => (
                        <Command.Item
                          key={file.path}
                          value={`change:${file.path}`}
                          className="task-search-item"
                          onSelect={() => run(() => onOpenFile(file.path))}
                        >
                          <FileIcon path={file.path} size={14} />
                          <span className="task-search-title">
                            {file.path.startsWith(`${workspaceKey}/`)
                              ? file.path.slice(workspaceKey.length + 1)
                              : file.path}
                          </span>
                          <span className="task-search-metadata">
                            <span className="task-search-added">+{file.added}</span>{" "}
                            <span className="task-search-removed">-{file.removed}</span>
                          </span>
                        </Command.Item>
                      ))
                    ) : (
                      <div className="task-search-empty">当前任务暂无最近变更</div>
                    )}
                  </Section>
                )}
              {!query && searchingTasks && (recentRows.length > 0 || scope === "conversations") && (
                <Section title="最近任务">
                  {loadingTasks ? (
                    <div className="task-search-empty">正在搜索任务…</div>
                  ) : recentRows.length ? (
                    recentRows.map((task) => taskItem(task))
                  ) : (
                    <div className="task-search-empty">暂无最近任务</div>
                  )}
                </Section>
              )}
              {(scope === "all" || scope === "commands") &&
                ["建议", "面板", "配置"].map((section) => {
                  const items = commandRows.filter((command) => command.section === section);
                  return items.length ? (
                    <Section title={section} key={section}>
                      {items.map((command) => (
                        <Command.Item
                          key={command.id}
                          value={command.id}
                          disabled={command.disabled}
                          className="task-search-item"
                          onSelect={() => run(command.run, "commands")}
                        >
                          <command.icon size={14} className="task-search-icon" />
                          <span className="task-search-title">
                            <HighlightedMatchText text={command.title} query={query} />
                          </span>
                          {command.shortcut && (
                            <span className="task-search-shortcut">{command.shortcut}</span>
                          )}
                        </Command.Item>
                      ))}
                    </Section>
                  ) : null;
                })}
              {query && searchingTasks && (rows.length > 0 || loadingTasks || taskError) && (
                <Section title="任务">
                  {taskError ? (
                    <div role="alert" className="task-search-empty">
                      {taskError}
                    </div>
                  ) : loadingTasks ? (
                    <div className="task-search-empty">正在搜索任务…</div>
                  ) : (
                    visibleRows.map((row) => taskItem(row.task, row.key, row.snippet))
                  )}
                  {more("conversations", rows.length)}
                </Section>
              )}
              {query && searchingFiles && (fileSearch.result || fileLoading || fileError) && (
                <Section title="文件">
                  {fileError ? (
                    <div role="alert" className="task-search-empty">
                      {fileError}
                    </div>
                  ) : fileLoading && !fileSearch.result ? (
                    <div className="task-search-empty">正在加载文件…</div>
                  ) : (
                    visibleFiles.map((file) => (
                      <Command.Item
                        key={file.path}
                        value={`file:${file.path}`}
                        className="task-search-item"
                        onSelect={() => run(() => onOpenFile(file.absolutePath), "files")}
                      >
                        <FileIcon path={file.path} size={14} />
                        <span className="task-search-title">
                          <HighlightedMatchText text={file.name} query={query} />
                        </span>
                        {file.path.includes("/") && (
                          <span className="task-search-metadata">
                            <HighlightedMatchText
                              text={file.path.slice(0, file.path.lastIndexOf("/"))}
                              query={query}
                            />
                          </span>
                        )}
                      </Command.Item>
                    ))
                  )}
                  {more("files", fileRows.length)}
                  <FileSearchStatus
                    result={fileSearch.result}
                    loading={fileLoading}
                    onContinue={fileSearch.loadMore}
                    error={fileError}
                  />
                </Section>
              )}
              {query &&
                !hasResults &&
                !loadingTasks &&
                !fileLoading &&
                !taskError &&
                (!searchingFiles || !fileError) && <div className="task-search-empty">暂无相关结果</div>}
            </Command.List>

            {!query && history.length > 0 && (
              <>
                <div className="task-search-history-heading">
                  搜索历史
                  <button
                    type="button"
                    aria-label="清除搜索历史"
                    onClick={() => {
                      clearCommandCenterSearchHistory(workspaceKey);
                      setHistory([]);
                    }}
                  >
                    <Trash2 size={14} />
                  </button>
                </div>
                <div className={`task-search-history ${historyExpanded ? "expanded" : ""}`}>
                  {history.map((entry: CommandCenterSearchHistoryEntry) => (
                    <button
                      type="button"
                      key={entry.query}
                      className="task-search-history-chip"
                      onClick={() => {
                        setManualScope(entry.scope);
                        setRaw(`${searchScopePrefix(entry.scope)}${entry.query}`);
                        input.current?.focus();
                      }}
                    >
                      {searchScopePrefix(entry.scope) && <span>{searchScopePrefix(entry.scope)}</span>}
                      <span>{entry.query}</span>
                    </button>
                  ))}
                  {history.length > 6 && (
                    <button
                      type="button"
                      className="task-search-history-expand"
                      aria-label={historyExpanded ? "折叠搜索历史" : "展开搜索历史"}
                      onClick={() => setHistoryExpanded(!historyExpanded)}
                    >
                      <ChevronDown
                        size={14}
                        style={{ transform: historyExpanded ? "rotate(180deg)" : undefined }}
                      />
                    </button>
                  )}
                </div>
              </>
            )}
          </Command>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}