Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/renderer/workspace-file-tree/model.ts

typescript402 行查看原文
// Adapted from ZCode workspace-file-tree; see licenses/third-party-notices.txt.
import type {
  WorkspaceDirectoryEntry as FileEntry,
  WorkspaceGitChange as GitFileChange,
  WorkspaceGitSnapshot,
  WorkspaceGitStatus,
} from "../../shared/workspace-files.ts";

type GitRepositorySummary = WorkspaceGitSnapshot["summary"];
const getPathLeaf = (path: string) => path.split(/[\\/]/).at(-1) ?? path;

export interface WorkspaceFileTreeNode {
  path: string;
  name: string;
  type: FileEntry["type"];
  depth: number;
  isSymbolicLink?: boolean;
}

export interface WorkspaceFileTreeRow extends WorkspaceFileTreeNode {
  expanded: boolean;
  loaded: boolean;
  loading: boolean;
  error: Error | null;
  compactedPaths?: string[];
}

export type WorkspaceFileGitStatus = WorkspaceGitStatus;

const WORKSPACE_FILE_GIT_STATUS_PRIORITY: Record<WorkspaceFileGitStatus, number> = {
  ignored: 0,
  modified: 1,
  renamed: 2,
  deleted: 3,
  added: 4,
  untracked: 5,
};

const WORKSPACE_FILE_GIT_DIRECTORY_STATUS_PRIORITY: Record<WorkspaceFileGitStatus, number> = {
  added: 1,
  deleted: 1,
  renamed: 1,
  untracked: 1,
  modified: 2,
  ignored: 3,
};

function normalizeForRelativePath(path: string): string {
  return path.replace(/\\/g, "/").replace(/\/+$/, "");
}

function resolveWorkspaceFileGitStatus(
  change: Pick<GitFileChange, "isUntracked" | "kind" | "section">,
): WorkspaceFileGitStatus {
  if (change.isUntracked || change.section === "untracked") {
    return "untracked";
  }

  return change.kind;
}

export function buildWorkspaceFileGitStatusByPath(
  changes: readonly Pick<GitFileChange, "path" | "isUntracked" | "kind" | "section">[],
): Map<string, WorkspaceFileGitStatus> {
  const statusByPath = new Map<string, WorkspaceFileGitStatus>();

  for (const change of changes) {
    const pathKey = normalizeForRelativePath(change.path);
    const nextStatus = resolveWorkspaceFileGitStatus(change);
    const existingStatus = statusByPath.get(pathKey);
    // 修复:文件树之前把 Git 改动统一压成 M/U,导致 staged added/deleted/renamed
    // 在树里丢失真实状态。这里保留优先级更高的状态,兼容同一文件同时存在 staged/unstaged 记录。
    if (
      existingStatus &&
      WORKSPACE_FILE_GIT_STATUS_PRIORITY[existingStatus] >= WORKSPACE_FILE_GIT_STATUS_PRIORITY[nextStatus]
    ) {
      continue;
    }

    statusByPath.set(pathKey, nextStatus);
  }

  return statusByPath;
}

export function isWorkspaceFileTreeGitStatusAvailable(
  summary: Pick<GitRepositorySummary, "isGitAvailable" | "isRepository">,
): boolean {
  // 修复:`getChanges()` 在非 Git workspace 里也会返回空数组,不能把“读取成功”
  // 当成 Git 状态可用。文件树只在 Git 可执行且当前 workspace 属于仓库时展示变更过滤。
  return summary.isGitAvailable && summary.isRepository;
}

export function getWorkspaceFileGitStatus(
  statusByPath: ReadonlyMap<string, WorkspaceFileGitStatus>,
  filePath: string,
): WorkspaceFileGitStatus | null {
  return statusByPath.get(normalizeForRelativePath(filePath)) ?? null;
}

export function buildWorkspaceFileIgnoredPathSet(paths: readonly string[]): Set<string> {
  return new Set(paths.map((path) => normalizeForRelativePath(path)));
}

export function isWorkspaceFileTreeAutoFlattenableDirectory(
  node: Pick<WorkspaceFileTreeNode, "type" | "isSymbolicLink"> | undefined,
): node is WorkspaceFileTreeNode {
  // 软链接目录可以展示并手动进入,但不能参与空目录链自动展开;
  // self/parent symlink 会让路径字符串去重失效,持续加载 linked-dir/linked-dir/...。
  return node?.type === "directory" && node.isSymbolicLink !== true;
}

/** Aggregate once per Git snapshot so scrolling and filtering only look up directory decorations. */
export function buildWorkspaceDirectoryGitStatuses(
  statusByPath: ReadonlyMap<string, WorkspaceFileGitStatus>,
): Map<string, WorkspaceFileGitStatus[]> {
  const statusesByDirectory = new Map<string, Set<WorkspaceFileGitStatus>>();
  for (const [path, status] of statusByPath) {
    let directory = normalizeForRelativePath(path);
    for (let slash = directory.lastIndexOf("/"); slash >= 0; slash = directory.lastIndexOf("/")) {
      directory = directory.slice(0, slash);
      let statuses = statusesByDirectory.get(directory);
      if (!statuses) {
        statuses = new Set();
        statusesByDirectory.set(directory, statuses);
      }
      statuses.add(status);
    }
  }
  return new Map(
    [...statusesByDirectory].map(([directory, statuses]) => [
      directory,
      [...statuses].sort(
        (left, right) =>
          WORKSPACE_FILE_GIT_DIRECTORY_STATUS_PRIORITY[right] -
          WORKSPACE_FILE_GIT_DIRECTORY_STATUS_PRIORITY[left],
      ),
    ]),
  );
}

export function getWorkspaceDirectoryGitStatuses(
  statusesByDirectory: ReadonlyMap<string, WorkspaceFileGitStatus[]>,
  directoryPath: string,
): WorkspaceFileGitStatus[] {
  return statusesByDirectory.get(normalizeForRelativePath(directoryPath)) ?? [];
}

export function addDeletedGitStatusRowsToWorkspaceFileTree(params: {
  rootPath: string;
  childrenByDirectory: Map<string, WorkspaceFileTreeNode[]>;
  statusByPath: ReadonlyMap<string, WorkspaceFileGitStatus>;
}): Map<string, WorkspaceFileTreeNode[]> {
  let nextChildrenByDirectory: Map<string, WorkspaceFileTreeNode[]> | null = null;

  for (const [path, status] of params.statusByPath) {
    if (status !== "deleted") {
      continue;
    }

    const parentDirectory = getWorkspaceFileParentDirectory(params.rootPath, path);
    if (!parentDirectory) {
      continue;
    }

    const currentChildren =
      (nextChildrenByDirectory ?? params.childrenByDirectory).get(parentDirectory) ?? null;
    if (!currentChildren) {
      continue;
    }

    if (currentChildren.some((child) => areWorkspaceFilePathsEqual(child.path, path))) {
      continue;
    }

    const nextChildren = [
      ...currentChildren,
      {
        path,
        name: getPathLeaf(path),
        type: "file" as const,
        depth: getWorkspaceFileDirectoryChildDepth(params.rootPath, parentDirectory),
      },
    ].sort((left, right) => {
      if (left.type !== right.type) {
        return left.type === "directory" ? -1 : 1;
      }

      return left.name.localeCompare(right.name);
    });

    // 修复:deleted 文件已不在文件系统里,单靠 readdir 无法生成行,
    // 导致文件树只能在目录上显示聚合点,看不到具体文件的 D 状态。
    nextChildrenByDirectory ??= new Map(params.childrenByDirectory);
    nextChildrenByDirectory.set(parentDirectory, nextChildren);
  }

  return nextChildrenByDirectory ?? params.childrenByDirectory;
}

export function getWorkspaceFileRelativePath(workspacePath: string, filePath: string): string {
  const normalizedWorkspacePath = normalizeForRelativePath(workspacePath);
  const normalizedFilePath = normalizeForRelativePath(filePath);

  if (normalizedWorkspacePath === normalizedFilePath) {
    return ".";
  }

  const workspacePrefix = `${normalizedWorkspacePath}/`;
  if (normalizedFilePath.startsWith(workspacePrefix)) {
    return normalizedFilePath.slice(workspacePrefix.length);
  }

  return getPathLeaf(filePath);
}

export function areWorkspaceFilePathsEqual(leftPath: string, rightPath: string): boolean {
  return normalizeForRelativePath(leftPath) === normalizeForRelativePath(rightPath);
}

export function isWorkspaceFilePathInside(workspacePath: string, filePath: string): boolean {
  const normalizedWorkspacePath = normalizeForRelativePath(workspacePath);
  const normalizedFilePath = normalizeForRelativePath(filePath);
  return (
    normalizedFilePath === normalizedWorkspacePath ||
    normalizedFilePath.startsWith(`${normalizedWorkspacePath}/`)
  );
}

export function getWorkspaceFileAncestorDirectories(workspacePath: string, filePath: string): string[] {
  const normalizedWorkspacePath = normalizeForRelativePath(workspacePath);
  const normalizedFilePath = normalizeForRelativePath(filePath);
  const workspacePrefix = `${normalizedWorkspacePath}/`;

  if (!isWorkspaceFilePathInside(workspacePath, filePath) || normalizedFilePath === normalizedWorkspacePath) {
    return [];
  }

  const relativePath = normalizedFilePath.slice(workspacePrefix.length);
  const relativeSegments = relativePath.split("/").filter(Boolean);
  if (relativeSegments.length <= 1) {
    return [];
  }

  const separator = filePath.includes("\\") && !filePath.includes("/") ? "\\" : "/";
  const basePath = workspacePath.replace(/[\\/]+$/, "");
  const ancestorSegments = relativeSegments.slice(0, -1);
  const ancestors: string[] = [];
  let currentPath = basePath;

  for (const segment of ancestorSegments) {
    currentPath = `${currentPath}${separator}${segment}`;
    ancestors.push(currentPath);
  }

  return ancestors;
}

export function getWorkspaceFileDirectoryChildDepth(workspacePath: string, directoryPath: string): number {
  const normalizedWorkspacePath = normalizeForRelativePath(workspacePath);
  const normalizedDirectoryPath = normalizeForRelativePath(directoryPath);

  if (
    normalizedDirectoryPath === normalizedWorkspacePath ||
    !isWorkspaceFilePathInside(workspacePath, directoryPath)
  ) {
    return 0;
  }

  const workspacePrefix = `${normalizedWorkspacePath}/`;
  return normalizedDirectoryPath.slice(workspacePrefix.length).split("/").filter(Boolean).length;
}

export function getWorkspaceFileParentDirectory(workspacePath: string, directoryPath: string): string | null {
  const normalizedWorkspacePath = normalizeForRelativePath(workspacePath);
  const normalizedDirectoryPath = normalizeForRelativePath(directoryPath);

  if (
    normalizedDirectoryPath === normalizedWorkspacePath ||
    !isWorkspaceFilePathInside(workspacePath, directoryPath)
  ) {
    return null;
  }

  const workspacePrefix = `${normalizedWorkspacePath}/`;
  const relativeSegments = normalizedDirectoryPath.slice(workspacePrefix.length).split("/").filter(Boolean);
  const parentSegments = relativeSegments.slice(0, -1);
  if (parentSegments.length === 0) {
    return workspacePath.replace(/[\\/]+$/, "");
  }

  const separator = directoryPath.includes("\\") && !directoryPath.includes("/") ? "\\" : "/";
  return `${workspacePath.replace(/[\\/]+$/, "")}${separator}${parentSegments.join(separator)}`;
}

function isWorkspaceFileTreeRowChanged(
  row: WorkspaceFileTreeRow,
  statusByPath: ReadonlyMap<string, WorkspaceFileGitStatus>,
  directoryStatusesByPath: ReadonlyMap<string, WorkspaceFileGitStatus[]>,
): boolean {
  const gitStatus = getWorkspaceFileGitStatus(statusByPath, row.path);
  if (gitStatus && gitStatus !== "ignored") {
    return true;
  }

  return (
    row.type === "directory" && getWorkspaceDirectoryGitStatuses(directoryStatusesByPath, row.path).length > 0
  );
}

export function filterWorkspaceFileTreeRows(params: {
  rows: WorkspaceFileTreeRow[];
  changedOnly: boolean;
  statusByPath: ReadonlyMap<string, WorkspaceFileGitStatus>;
  directoryStatusesByPath: ReadonlyMap<string, WorkspaceFileGitStatus[]>;
}): WorkspaceFileTreeRow[] {
  return params.changedOnly
    ? params.rows.filter((row) =>
        isWorkspaceFileTreeRowChanged(row, params.statusByPath, params.directoryStatusesByPath),
      )
    : params.rows;
}

export function flattenWorkspaceFileTreeRows(params: {
  rootPath: string;
  childrenByDirectory: Map<string, WorkspaceFileTreeNode[]>;
  expandedPaths: Set<string>;
  loadedDirectoryPaths: Set<string>;
  loadingDirectoryPaths: Set<string>;
  errorByDirectory: Map<string, Error>;
}): WorkspaceFileTreeRow[] {
  const rows: WorkspaceFileTreeRow[] = [];

  function compactDirectoryNode(node: WorkspaceFileTreeNode): {
    node: WorkspaceFileTreeNode;
    compactedPaths?: string[];
    childDepthOffset: number;
  } {
    if (!isWorkspaceFileTreeAutoFlattenableDirectory(node)) {
      return { node, childDepthOffset: 0 };
    }

    const compactedNodes = [node];
    let currentNode = node;

    while (
      params.loadedDirectoryPaths.has(currentNode.path) &&
      !params.loadingDirectoryPaths.has(currentNode.path) &&
      !params.errorByDirectory.has(currentNode.path)
    ) {
      const children = params.childrenByDirectory.get(currentNode.path) ?? [];
      if (children.length !== 1 || !isWorkspaceFileTreeAutoFlattenableDirectory(children[0])) {
        break;
      }

      currentNode = children[0];
      compactedNodes.push(currentNode);
    }

    if (compactedNodes.length === 1) {
      return { node, childDepthOffset: 0 };
    }

    return {
      node: {
        ...currentNode,
        depth: node.depth,
        name: compactedNodes.map((compactedNode) => compactedNode.name).join("/"),
      },
      compactedPaths: compactedNodes.map((compactedNode) => compactedNode.path),
      childDepthOffset: currentNode.depth - node.depth,
    };
  }

  function visit(directoryPath: string, depthOffset = 0) {
    const children = params.childrenByDirectory.get(directoryPath) ?? [];
    for (const child of children) {
      const compacted = compactDirectoryNode({
        ...child,
        depth: child.depth - depthOffset,
      });
      const representedPaths = compacted.compactedPaths ?? [compacted.node.path];
      const expanded = representedPaths.some((path) => params.expandedPaths.has(path));
      rows.push({
        ...compacted.node,
        expanded,
        loaded: params.loadedDirectoryPaths.has(compacted.node.path),
        loading: params.loadingDirectoryPaths.has(compacted.node.path),
        error: params.errorByDirectory.get(compacted.node.path) ?? null,
        ...(compacted.compactedPaths ? { compactedPaths: compacted.compactedPaths } : {}),
      });

      if (compacted.node.type === "directory" && expanded) {
        visit(compacted.node.path, depthOffset + compacted.childDepthOffset);
      }
    }
  }

  visit(params.rootPath);
  return rows;
}