Files
paseo/packages/app/src/file-explorer/tree.ts
T
paseo-bot[bot]andMoy fa70b215a8 Open chat folder links in Files with their contents visible (#7205)
* fix(app): open folders from inline path links in the file explorer

Clicking a folder path in an assistant message opened a file tab that
failed with "Requested path is not a file". Extension-less
workspace-relative paths without a line number are now probed with a
directory listing before opening: directories open the Files explorer
navigated to that folder, everything else opens as a file like before.
Lexical directories (trailing slash, workspace root) now navigate the
explorer to the folder instead of only preloading its listing.

* fix(app): reveal linked folders and their contents

Resolve workspace folder links from directory metadata and expand their ancestor paths in Files. Preserve file and line-reference opening, including extensionless files.

Co-authored-by: Moy <moyfoxther@gmail.com>

* fix(app): cancel stale path opens and reveal hidden folders

Keep pending directory checks inside the path-opening model so a later click or navigation supersedes them. Explicitly revealing a hidden directory also enables the existing hidden-file visibility preference.

---------

Co-authored-by: Moy <moyfoxther@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 09:53:28 +02:00

225 lines
6.4 KiB
TypeScript

import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
import type { SortOption } from "@/stores/panel-store/state";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "./visibility";
export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5;
export interface ExplorerTreeRow {
entry: ExplorerEntry;
depth: number;
}
interface FlattenExplorerTreeInput {
directories: ReadonlyMap<string, ExplorerDirectory>;
expandedPaths: ReadonlySet<string>;
sortOption: SortOption;
showHiddenFiles: boolean;
}
interface RestoreExpandedDirectoriesInput {
rootDirectory: ExplorerDirectory;
persistedExpandedPaths: ReadonlySet<string>;
showHiddenFiles: boolean;
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
}
interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
RestoreExpandedDirectoriesInput,
"showHiddenFiles"
> {
showHiddenFiles: () => void;
}
interface ReconcileRestoredExpandedPathsInput {
persistedExpandedPaths: ReadonlySet<string>;
currentExpandedPaths: ReadonlySet<string>;
restoredExpandedPaths: string[];
}
interface SetExpandedDirectoryPathInput {
currentExpandedPaths: readonly string[];
directoryPath: string;
expanded: boolean;
}
export function flattenExplorerTree({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
}: FlattenExplorerTreeInput): ExplorerTreeRow[] {
const root = directories.get(".");
if (!root) {
return [];
}
const rows: ExplorerTreeRow[] = [];
const pending = rowsForDirectory(root, 0, sortOption, showHiddenFiles).toReversed();
while (pending.length > 0) {
const row = pending.pop();
if (!row) {
break;
}
rows.push(row);
const entry = row.entry;
if (entry.kind !== "directory" || !expandedPaths.has(entry.path)) {
continue;
}
const childDirectory = directories.get(entry.path);
if (!childDirectory) {
continue;
}
const childRows = rowsForDirectory(childDirectory, row.depth + 1, sortOption, showHiddenFiles);
for (let index = childRows.length - 1; index >= 0; index -= 1) {
pending.push(childRows[index]);
}
}
return rows;
}
export async function restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
}: RestoreExpandedDirectoriesInput): Promise<string[]> {
const restoredPaths = ["."];
const restoredPathSet = new Set(restoredPaths);
let parentDirectories = [rootDirectory];
for (let depth = 1; depth <= MAX_AUTO_EXPANDED_DIRECTORY_DEPTH; depth += 1) {
const pathsToRequest: string[] = [];
for (const directory of parentDirectories) {
const entries = filterVisibleExplorerEntries(directory.entries, showHiddenFiles);
for (const entry of entries) {
const isPersistedExpandedDirectory =
entry.kind === "directory" && persistedExpandedPaths.has(entry.path);
if (isPersistedExpandedDirectory && !restoredPathSet.has(entry.path)) {
pathsToRequest.push(entry.path);
restoredPathSet.add(entry.path);
}
}
}
if (pathsToRequest.length === 0) {
break;
}
const requestedDirectories = await Promise.all(
pathsToRequest.map((path) => requestDirectoryListing(path)),
);
parentDirectories = [];
for (const directory of requestedDirectories) {
if (!directory) {
continue;
}
restoredPaths.push(directory.path);
parentDirectories.push(directory);
}
}
return restoredPaths;
}
export function showHiddenFilesAndRestoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<string[]> {
showHiddenFiles();
return restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths,
showHiddenFiles: true,
requestDirectoryListing,
});
}
export function reconcileRestoredExpandedPaths({
persistedExpandedPaths,
currentExpandedPaths,
restoredExpandedPaths,
}: ReconcileRestoredExpandedPathsInput): string[] {
const reconciledPaths = new Set(restoredExpandedPaths);
for (const path of persistedExpandedPaths) {
if (!currentExpandedPaths.has(path)) {
reconciledPaths.delete(path);
}
}
for (const path of currentExpandedPaths) {
if (!persistedExpandedPaths.has(path)) {
reconciledPaths.add(path);
}
}
return Array.from(reconciledPaths);
}
export function setExpandedDirectoryPath({
currentExpandedPaths,
directoryPath,
expanded,
}: SetExpandedDirectoryPathInput): string[] {
const nextPaths = new Set(currentExpandedPaths);
if (expanded) {
nextPaths.add(directoryPath);
} else {
nextPaths.delete(directoryPath);
}
return Array.from(nextPaths);
}
function rowsForDirectory(
directory: ExplorerDirectory,
depth: number,
sortOption: SortOption,
showHiddenFiles: boolean,
): ExplorerTreeRow[] {
const visibleEntries = filterVisibleExplorerEntries(directory.entries, showHiddenFiles);
const sortedEntries = sortExplorerEntries(visibleEntries, sortOption);
return sortedEntries.map((entry) => ({ entry, depth }));
}
function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
const sorted = [...entries];
sorted.sort((a, b) => {
if (a.kind !== b.kind) {
return a.kind === "directory" ? -1 : 1;
}
switch (sortOption) {
case "name":
return a.name.localeCompare(b.name);
case "modified":
return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
case "size":
return b.size - a.size;
}
});
return sorted;
}
/** Load and expand the path to a directory selected outside the tree. */
export async function revealExplorerDirectory(input: {
directoryPath: string;
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
expandPaths: (paths: string[]) => void;
showHiddenFiles: () => void;
}): Promise<void> {
if (isHiddenExplorerPath(input.directoryPath)) input.showHiddenFiles();
const paths = ["."];
if (input.directoryPath !== ".") {
const segments = input.directoryPath.split("/").filter(Boolean);
for (let length = 1; length <= segments.length; length += 1) {
paths.push(segments.slice(0, length).join("/"));
}
}
for (const path of paths) {
if (!(await input.requestDirectoryListing(path))) return;
}
input.expandPaths(paths);
}