Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import { AudioPreview } from "./AudioPreview";
import { BrowserDocumentFrame, isPdfPreviewFile } from "./BrowserDocumentFrame";
import { DelimitedTablePreview } from "./DelimitedTablePreview";
import FileBrowserPanel from "./FileBrowserPanel";
import { HostFolderListing } from "./HostFolderListing";
import { FileBreadcrumbs } from "./FileBreadcrumbs";
import { FileMarkdownPreview } from "./FileMarkdownPreview";
import {
Expand Down Expand Up @@ -1087,8 +1088,10 @@ export default function FilePreviewPanel({
// a file surface and the read fails. Keep the breadcrumbs, drop the preview
// pane, and let the tree fill the surface with the folder revealed. Mutation
// refresh stays on so the surface notices if the path becomes a file. A host
// path cannot be revealed in the workspace tree, so it keeps the read error.
// path cannot be revealed in the workspace tree, so it gets a flat listing.
const isDirectory = file.isNotFile && !isHostFile;
// A host folder gets its own read-only listing instead of the tree.
const isHostFolder = file.isNotFile && isHostFile && attachment === undefined;
// Everything preview-related keys off previewPath; a folder has no preview.
const previewPath = isDirectory ? null : relativePath;
const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
Expand Down Expand Up @@ -1344,7 +1347,13 @@ export default function FilePreviewPanel({
<div
className={cn("min-w-0 flex-1 flex-col overflow-hidden", previewPath ? "flex" : "hidden")}
>
{isDirectory ? null : relativePath && attachment ? (
{isDirectory ? null : isHostFolder && absolutePath ? (
<HostFolderListing
environmentId={environmentId}
folderPath={absolutePath}
onOpenPath={onOpenFile}
/>
) : relativePath && attachment ? (
<AttachmentFilePreview
key={`${environmentId}:${attachment.id}`}
name={attachment.name}
Expand Down
72 changes: 72 additions & 0 deletions apps/web/src/components/files/HostFolderListing.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { EnvironmentId } from "@t3tools/contracts";
import { ArrowLeftIcon } from "lucide-react";
import { useMemo } from "react";

import { PierreEntryIcon } from "~/components/chat/PierreEntryIcon";
import { useTheme } from "~/hooks/useTheme";
import { cn } from "~/lib/utils";

import { fileBreadcrumbChildren, hostFolderChildPath, hostFolderParent } from "./filePath";
import { FileSurfaceFailure, FileSurfaceLoading } from "./fileSurfaceChrome";
import { useProjectEntriesQuery } from "./projectFilesQueryState";

const ROW_CLASS =
"flex w-full min-w-0 items-center gap-2 rounded-sm px-2 py-1 text-left text-xs outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring";

/**
* Read-only listing for a folder outside the workspace, reached from a chat link.
* The workspace tree cannot reveal host paths, so entries open through the same
* file surface: a file previews, a folder lands back here one level down.
*/
export function HostFolderListing(props: {
readonly environmentId: EnvironmentId;
readonly folderPath: string;
readonly onOpenPath: (absolutePath: string) => void;
}) {
const { resolvedTheme } = useTheme();
// An empty directoryPath lists the folder's immediate children, ignored entries included.
const entriesQuery = useProjectEntriesQuery(props.environmentId, props.folderPath, "");
const children = useMemo(
() => fileBreadcrumbChildren(entriesQuery.data?.entries ?? [], ""),
[entriesQuery.data],
);
const parentPath = hostFolderParent(props.folderPath);

if (entriesQuery.data === null) {
return entriesQuery.error && !entriesQuery.isPending ? (
<FileSurfaceFailure message={entriesQuery.error} onRetry={entriesQuery.refresh} />
) : (
<FileSurfaceLoading />
);
}

return (
<div className="flex min-h-0 flex-1 flex-col overflow-auto p-2" data-host-folder-listing>
{parentPath !== null ? (
<button
type="button"
className={cn(ROW_CLASS, "text-muted-foreground")}
onClick={() => props.onOpenPath(parentPath)}
>
<ArrowLeftIcon className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">Up to {parentPath}</span>
</button>
) : null}
{children.length === 0 ? (
<p className="px-2 py-1 text-xs text-muted-foreground">This folder is empty.</p>
) : (
children.map((entry) => (
<button
key={entry.path}
type="button"
className={cn(ROW_CLASS, entry.ignored && "text-muted-foreground")}
onClick={() => props.onOpenPath(hostFolderChildPath(props.folderPath, entry.label))}
>
<PierreEntryIcon pathValue={entry.path} kind={entry.kind} theme={resolvedTheme} />
<span className="min-w-0 flex-1 truncate">{entry.label}</span>
</button>
))
)}
</div>
);
}
30 changes: 29 additions & 1 deletion apps/web/src/components/files/filePath.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
import { describe, expect, it } from "vite-plus/test";

import { fileBreadcrumbChildren, fileBreadcrumbParent, fileBreadcrumbs } from "./filePath";
import {
fileBreadcrumbChildren,
fileBreadcrumbParent,
fileBreadcrumbs,
hostFolderChildPath,
hostFolderParent,
} from "./filePath";

describe("host folder paths", () => {
it.each([
["/home/me/previews", "a.png", "/home/me/previews/a.png"],
["/home/me/previews/", "a.png", "/home/me/previews/a.png"],
["/", "etc", "/etc"],
["C:\\Users\\me", "a.png", "C:\\Users\\me\\a.png"],
])("joins %j and %j", (folder, name, expected) => {
expect(hostFolderChildPath(folder, name)).toBe(expected);
});

it.each([
["/home/me/previews", "/home/me"],
["/home/me/previews/", "/home/me"],
["/home", "/"],
["/", null],
["C:\\Users\\me", "C:\\Users"],
["C:\\Users", "C:\\"],
])("returns the parent of %j", (folder, expected) => {
expect(hostFolderParent(folder)).toBe(expected);
});
});

describe("fileBreadcrumbs", () => {
it("builds project, directory, and file crumbs", () => {
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/components/files/filePath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,23 @@ export function fileBreadcrumbChildren(
});
}

/** Join a child name onto an absolute host folder, keeping the folder's separator style. */
export function hostFolderChildPath(folderPath: string, name: string): string {
const separator = isWindowsAbsolutePath(folderPath) ? "\\" : "/";
return `${folderPath.replace(/[\\/]+$/, "")}${separator}${name}`;
}

/** Parent of an absolute host folder, or null at a filesystem root. */
export function hostFolderParent(folderPath: string): string | null {
const trimmed = folderPath.replace(/[\\/]+$/, "");
const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
if (separatorIndex < 0) return null;
if (separatorIndex === 0) return trimmed.length > 1 ? "/" : null;
const parent = trimmed.slice(0, separatorIndex);
// `C:` alone is drive-relative; the drive root keeps its separator.
return /^[A-Za-z]:$/.test(parent) ? `${parent}\\` : parent;
}

export function fileBreadcrumbParent(directoryPath: string): string | null {
if (!directoryPath) return null;
const separatorIndex = directoryPath.lastIndexOf("/");
Expand Down
Loading