Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/renderer/TaskGroupRow.tsx

tsx334 行查看原文
import { useDraggable, useDroppable } from "@dnd-kit/core";
import * as Context from "@radix-ui/react-context-menu";
import * as Tooltip from "@radix-ui/react-tooltip";
import { ActionHint, TaskTitleOverflowText } from "@zpi-dev/ui";
import { Archive, ArrowUpToLine, ChevronRight, Folder, FolderInput, LoaderIcon, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { SessionRecord } from "../shared/bridge.ts";
import { orderedTaskGroups, type TaskGroupState, taskGroupFor } from "../shared/task-groups.ts";
import { deleteSessionWithPanes } from "./pane-store.ts";
import { QuestionBadge } from "./SidebarTaskRow.tsx";
import { formatTaskRelativeTime } from "./sidebar-task-model.ts";
import {
  archiveSession,
  changeTaskGroups,
  forgetDiscardedDraft,
  prepareDiscardDraft,
  refresh,
  report,
  selectSession,
} from "./store.ts";
import { TaskMenuItems } from "./TaskMenuItems.tsx";
import { TaskGroupColorMark } from "./task-group-colors.tsx";
import { unwrap } from "./unwrap.ts";

export function TaskGroupRow({
  record,
  state,
  selected,
  dragging = false,
  overlay = false,
  tooltipsDisabled = false,
  interactionDisabled = false,
}: {
  record: SessionRecord;
  state: TaskGroupState;
  selected?: string;
  dragging?: boolean;
  overlay?: boolean;
  tooltipsDisabled?: boolean;
  interactionDisabled?: boolean;
}) {
  const [menuOpen, setMenuOpen] = useState(false);
  const workspaceLabel = record.cwd?.replace(/\\/g, "/").split("/").filter(Boolean).at(-1) ?? "ZPI";
  const [renaming, setRenaming] = useState(false);
  const [title, setTitle] = useState(record.title);
  const input = useRef<HTMLInputElement>(null);
  const pendingRename = useRef(false);
  const composing = useRef(false);
  const draggable = useDraggable({
    id: `task:${record.id}`,
    disabled: interactionDisabled || overlay || menuOpen || renaming || Boolean(record.draft),
    data: { type: "task", id: record.id },
  });
  const droppable = useDroppable({
    id: `task:${record.id}`,
    disabled: interactionDisabled || overlay || Boolean(record.draft),
    data: { type: "task", id: record.id },
  });
  const groupId = taskGroupFor(state, record.id);
  const run = (work: () => Promise<unknown>) => void work().catch(report);
  const moveTop = () => run(() => changeTaskGroups({ type: "taskTop", id: record.id }));
  const close = () => run(() => archiveSession(record.id));
  useEffect(() => {
    if (renaming) {
      input.current?.focus();
      input.current?.select();
    }
  }, [renaming]);
  const commitRename = () => {
    if (!renaming) return;
    setRenaming(false);
    if (title.trim() && title.trim() !== record.title)
      run(async () => {
        unwrap(await window.zpi.renameSession(record.id, title.trim()));
        await refresh();
      });
  };
  const indicator =
    record.status === "error"
      ? "error"
      : record.unreadAt !== undefined
        ? "unread"
        : record.running
          ? "running"
          : "none";
  const row = (
    // biome-ignore lint/a11y/noStaticElementInteractions: The overlay is inert; the interactive row has role=button.
    <div
      ref={(element) => {
        draggable.setNodeRef(element);
        droppable.setNodeRef(element);
      }}
      {...(!overlay && !renaming ? draggable.attributes : {})}
      {...(!overlay ? draggable.listeners : {})}
      role={overlay ? undefined : "button"}
      tabIndex={overlay ? undefined : 0}
      className={`grouped-task-row${selected === record.id ? " active" : ""}${overlay ? " task-group-drag-overlay" : ""}`}
      data-session-id={record.id}
      data-testid={overlay ? undefined : "session-row"}
      data-task-item-key={record.id}
      data-status={record.status ?? "idle"}
      data-indicator={indicator}
      data-dragging={dragging}
      onClick={() => {
        if (!renaming && !overlay) run(() => selectSession(record.id));
      }}
      onKeyDown={(event) => {
        if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) {
          event.preventDefault();
          run(() => selectSession(record.id));
        } else draggable.listeners?.onKeyDown?.(event);
      }}
    >
      {renaming ? (
        <input
          ref={input}
          aria-label="重命名任务"
          className="task-group-name-input"
          value={title}
          onChange={(event) => setTitle(event.target.value)}
          onBlur={commitRename}
          onClick={(event) => event.stopPropagation()}
          onPointerDown={(event) => event.stopPropagation()}
          onCompositionStart={() => {
            composing.current = true;
          }}
          onCompositionEnd={() => {
            composing.current = false;
          }}
          onKeyDown={(event) => {
            event.stopPropagation();
            if (event.key === "Escape") {
              event.preventDefault();
              setRenaming(false);
            } else if (
              event.key === "Enter" &&
              !event.nativeEvent.isComposing &&
              !composing.current &&
              event.keyCode !== 229
            )
              event.currentTarget.blur();
          }}
        />
      ) : (
        <TaskTitleOverflowText as="span">{record.draft ? "新任务" : record.title}</TaskTitleOverflowText>
      )}
      <span className="grouped-task-metadata">
        {record.pendingQuestion ? (
          <QuestionBadge record={record} />
        ) : (
          <>
            <span className="grouped-task-time">
              {indicator === "running" ? (
                <LoaderIcon size={14} className="task-running-icon" />
              ) : indicator !== "none" ? (
                <span className={`task-${indicator}-dot`} />
              ) : null}
              <span>{formatTaskRelativeTime(record.updatedAt)}</span>
            </span>
            {!overlay && (
              <span className="grouped-task-actions">
                <ActionHint
                  label="移动到顶部"
                  appearance="control"
                  open={tooltipsDisabled ? false : undefined}
                >
                  <button
                    aria-label={`移动到顶部 ${record.title}`}
                    onClick={(event) => {
                      event.stopPropagation();
                      moveTop();
                    }}
                  >
                    <ArrowUpToLine size={14} />
                  </button>
                </ActionHint>
                <ActionHint
                  label={record.running ? "请先停止运行" : "归档任务"}
                  appearance="control"
                  hideWhenDetached
                  open={tooltipsDisabled ? false : undefined}
                >
                  <span className="grouped-task-archive-hint">
                    <button
                      aria-label="归档任务"
                      disabled={record.running || Boolean(record.diagnostic)}
                      onClick={(event) => {
                        event.stopPropagation();
                        close();
                      }}
                    >
                      <Archive size={14} />
                    </button>
                  </span>
                </ActionHint>
              </span>
            )}
          </>
        )}
      </span>
    </div>
  );
  if (overlay) return row;
  if (record.draft)
    return (
      <div className="grouped-task-wrapper">
        {/* biome-ignore lint/a11y/useSemanticElements: Draft row contains its separate close button. */}
        <div
          className={`grouped-task-row grouped-draft-row${selected === record.id ? " active" : ""}`}
          role="button"
          tabIndex={0}
          data-session-id={record.id}
          data-testid="grouped-draft-row"
          onClick={() => run(() => selectSession(record.id))}
          onKeyDown={(event) => {
            if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) {
              event.preventDefault();
              run(() => selectSession(record.id));
            }
          }}
        >
          <span className="grouped-draft-title">新任务</span>
          <span className="grouped-draft-workspace">
            {record.cwd?.replace(/\\/g, "/").split("/").filter(Boolean).at(-1) ?? "ZPI"}
          </span>
          <span className="grouped-draft-close">
            <span className="grouped-draft-dot" />
            <button
              aria-label="关闭新任务"
              onClick={(event) => {
                event.stopPropagation();
                run(async () => {
                  await prepareDiscardDraft(record.id);
                  await deleteSessionWithPanes(record.id);
                  forgetDiscardedDraft(record.id);
                  await refresh();
                });
              }}
            >
              <X size={14} />
            </button>
          </span>
        </div>
      </div>
    );
  return (
    <Context.Root open={menuOpen} onOpenChange={setMenuOpen}>
      <Tooltip.Provider delayDuration={0}>
        <Tooltip.Root open={tooltipsDisabled || menuOpen ? false : undefined}>
          <Tooltip.Trigger asChild>
            <Context.Trigger asChild>
              <div className="grouped-task-wrapper">{row}</div>
            </Context.Trigger>
          </Tooltip.Trigger>
          <Tooltip.Portal>
            <Tooltip.Content
              className="message-action-tooltip control-hint-tooltip grouped-task-tooltip"
              side="right"
              align="center"
              sideOffset={6}
            >
              <Folder size={14} />
              <span>{workspaceLabel}</span>
            </Tooltip.Content>
          </Tooltip.Portal>
        </Tooltip.Root>
      </Tooltip.Provider>
      {menuOpen && (
        <Context.Portal>
          <Context.Content
            className="selection-menu task-menu grouped-task-context-menu"
            onCloseAutoFocus={(event) => {
              if (pendingRename.current) {
                event.preventDefault();
                pendingRename.current = false;
                setTitle(record.title);
                setRenaming(true);
              }
            }}
          >
            <Context.Sub>
              <Context.SubTrigger className="menu-item">
                <FolderInput size={16} />
                移动到分组
                <ChevronRight size={14} className="menu-sub-arrow" />
              </Context.SubTrigger>
              <Context.Portal>
                <Context.SubContent className="parity-menu task-group-move-menu">
                  <Context.Item
                    className="menu-item"
                    disabled={!groupId}
                    onSelect={() =>
                      run(() => changeTaskGroups({ type: "moveTask", id: record.id, groupId: null }))
                    }
                  >
                    移出分组
                  </Context.Item>
                  <Context.Separator className="menu-separator" />
                  {orderedTaskGroups(state).map((group) => (
                    <Context.Item
                      key={group.id}
                      className="menu-item"
                      disabled={group.id === groupId}
                      onSelect={() =>
                        run(() => changeTaskGroups({ type: "moveTask", id: record.id, groupId: group.id }))
                      }
                    >
                      <TaskGroupColorMark color={group.color} dot />
                      <span className="task-group-move-title">{group.title}</span>
                    </Context.Item>
                  ))}
                </Context.SubContent>
              </Context.Portal>
            </Context.Sub>
            <Context.Item className="menu-item" onSelect={moveTop}>
              <ArrowUpToLine size={16} />
              移动到顶部
            </Context.Item>
            <Context.Separator className="menu-separator" />
            <TaskMenuItems
              record={record}
              Item={Context.Item}
              Separator={Context.Separator}
              onRename={() => {
                pendingRename.current = true;
              }}
            />
          </Context.Content>
        </Context.Portal>
      )}
    </Context.Root>
  );
}