Skip to content
Merged
24 changes: 24 additions & 0 deletions apps/server/src/workspace/WorkspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,30 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => {
}),
);

it.effect("lists a host folder and its parent, each from its own root", () =>
// Folder links outside the workspace browse by passing the host folder as
// `cwd`; walking up passes the parent. Each stays bounded by its own root.
Effect.gen(function* () {
const parent = yield* makeTempDir();
const path = yield* Path.Path;
yield* writeTextFile(parent, "linked/notes.md");
yield* writeTextFile(parent, "sibling/readme.md");
const linked = path.join(parent, "linked");

const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
expect(yield* workspaceEntries.list({ cwd: linked, directoryPath: "" })).toEqual({
entries: [{ path: "notes.md", kind: "file" }],
truncated: false,
});
const up = yield* workspaceEntries.list({ cwd: parent, directoryPath: "" });
expect(up.entries.map((entry) => entry.path).toSorted()).toEqual(["linked", "sibling"]);
const escape = yield* workspaceEntries
.list({ cwd: linked, directoryPath: "../sibling" })
.pipe(Effect.flip);
expect(escape._tag).toBe("WorkspaceEntriesReadDirectoryError");
}),
);

it.effect(
"rejects directory traversal, git internals, and symlinks outside the workspace",
() =>
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10980,6 +10980,18 @@ export default function ChatView(props: ChatViewProps) {
: 0
}
onOpenFile={openFileSurfaceStable}
onNavigateFolder={(relativePath, folderTrail, keepSource) =>
useRightPanelStore
.getState()
.navigateFolder(
activeThreadRef,
renderedRightPanelSurface.id,
relativePath,
folderTrail,
keepSource,
)
}
folderTrail={renderedRightPanelSurface.folderTrail}
onPendingChange={handleFilePendingChangeStable}
selectedFilePending={
renderedRightPanelSurface.kind === "file" &&
Expand Down
142 changes: 101 additions & 41 deletions apps/web/src/components/files/FileBreadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,26 @@ import { cn } from "~/lib/utils";
import { isAbsolutePath } from "@t3tools/shared/path";

import {
breadcrumbPathOf,
type FileBreadcrumb,
fileBreadcrumbChildren,
fileBreadcrumbParent,
fileBreadcrumbs,
isListableHostFolder,
} from "./filePath";
import { useProjectEntriesQuery } from "./projectFilesQueryState";

interface FileBreadcrumbsProps {
readonly cwd: string;
readonly environmentId: EnvironmentId;
/** Moves the browser to a folder crumb; `""` is the workspace root. */
readonly onNavigateFolder: (relativePath: string) => void;
readonly onOpenFile: (relativePath: string) => void;
readonly projectName: string;
/** The current path; `""` is the workspace root. */
readonly relativePath: string;
/** The deepest path to show: `relativePath`, or a path inside it the browser came up from. */
readonly trail: string;
readonly workspaceMutationId: string | null;
}

Expand Down Expand Up @@ -213,7 +220,8 @@ function BreadcrumbMenuContent(props: {
);
}

function DirectoryBreadcrumb(props: FileBreadcrumbsProps & { readonly crumb: FileBreadcrumb }) {
/** The chevron after a folder crumb lists that folder's entries. */
function FolderContentsMenu(props: FileBreadcrumbsProps & { readonly crumb: FileBreadcrumb }) {
const [open, setOpen] = useState(false);
const [directoryPath, setDirectoryPath] = useState(props.crumb.path);

Expand All @@ -229,24 +237,17 @@ function DirectoryBreadcrumb(props: FileBreadcrumbsProps & { readonly crumb: Fil

return (
<Menu open={open} onOpenChange={handleOpenChange}>
<Tooltip>
<TooltipTrigger
render={
<MenuTrigger
render={
<button
type="button"
aria-label={`Browse ${props.crumb.label}`}
className="relative block max-w-40 cursor-pointer rounded-sm px-0.5 text-left text-muted-foreground outline-none pointer-coarse:after:-inset-y-3 pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-popup-open:bg-accent data-popup-open:text-foreground"
/>
}
/>
}
>
<span className="block truncate">{props.crumb.label}</span>
</TooltipTrigger>
<TooltipPopup side="top">{props.crumb.path || props.projectName}</TooltipPopup>
</Tooltip>
<MenuTrigger
render={
<button
type="button"
aria-label={`Browse ${props.crumb.label}`}
className="relative mx-0.5 flex h-5 w-4 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/60 outline-none pointer-coarse:after:absolute pointer-coarse:after:-inset-3 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-popup-open:bg-accent data-popup-open:text-foreground"
/>
}
>
<ChevronRightIcon className="size-3.5" />
</MenuTrigger>
{open ? (
<BreadcrumbMenuContent
cwd={props.cwd}
Expand All @@ -265,31 +266,90 @@ function DirectoryBreadcrumb(props: FileBreadcrumbsProps & { readonly crumb: Fil
);
}

function FolderBreadcrumb(props: {
readonly crumb: FileBreadcrumb;
/** Below the current folder, kept from where the browser came from. */
readonly descendant: boolean;
readonly onNavigate: (path: string) => void;
readonly projectName: string;
}) {
return (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
onClick={() => props.onNavigate(props.crumb.path)}
className={cn(
"relative block max-w-40 cursor-pointer rounded-sm px-0.5 text-left outline-none pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-3 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
props.descendant ? "text-muted-foreground/70" : "text-muted-foreground",
)}
/>
}
>
<span className="block truncate">{props.crumb.label}</span>
</TooltipTrigger>
<TooltipPopup side="top">{props.crumb.path || props.projectName}</TooltipPopup>
</Tooltip>
);
}

/**
* Each folder crumb moves the browser to that folder, and the chevron after it
* lists the folder's entries. A host path outside the workspace has no contents
* menu: the browser below already lists the folder it moves to.
*/
export function FileBreadcrumbs(props: FileBreadcrumbsProps) {
const hostPath = isAbsolutePath(props.relativePath);
const breadcrumbs = useMemo(
() => fileBreadcrumbs(props.projectName, props.relativePath),
[props.projectName, props.relativePath],
() => fileBreadcrumbs(props.projectName, props.trail),
[props.projectName, props.trail],
);
const currentPath = breadcrumbPathOf(props.relativePath);
const currentIndex = breadcrumbs.findIndex((crumb) => crumb.path === currentPath);

return breadcrumbs.map((crumb, index) => (
<div
key={crumb.path || "project"}
className="flex min-w-0 shrink-0 items-center"
data-current-file-crumb={crumb.kind === "file"}
>
{index > 0 ? (
<ChevronRightIcon className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" />
) : null}
{crumb.kind === "file" ? (
<span aria-current="page">
<BreadcrumbLabel current label={crumb.label} pathLabel={crumb.path} />
</span>
) : hostPath ? (
<BreadcrumbLabel label={crumb.label} pathLabel={crumb.path} />
) : (
<DirectoryBreadcrumb {...props} crumb={crumb} />
)}
</div>
));
return (
<nav aria-label="File path" className="flex h-full">
<ol className="flex items-center">
{breadcrumbs.map((crumb, index) => {
const current = index === currentIndex;
return (
<li
key={crumb.path || "project"}
className="flex min-w-0 shrink-0 items-center"
data-file-crumb
data-current-file-crumb={current}
>
{current ? (
<span aria-current="page">
<BreadcrumbLabel
current
label={crumb.label}
pathLabel={crumb.path || props.projectName}
/>
</span>
) : hostPath && !isListableHostFolder(crumb.path) ? (
<BreadcrumbLabel label={crumb.label} pathLabel={crumb.path} />
) : (
<FolderBreadcrumb
crumb={crumb}
descendant={index > currentIndex}
onNavigate={props.onNavigateFolder}
projectName={props.projectName}
/>
)}
{index === breadcrumbs.length - 1 ? null : hostPath ? (
<ChevronRightIcon
aria-hidden
className="mx-1 size-3.5 shrink-0 text-muted-foreground/60"
/>
) : (
<FolderContentsMenu {...props} crumb={crumb} />
)}
</li>
);
})}
</ol>
</nav>
);
}
25 changes: 20 additions & 5 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { readLocalApi } from "~/localApi";
import { T3_PIERRE_ICONS } from "~/pierre-icons";
import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme";

import { joinHostPath } from "./filePath";
import { createFileTreeDragMentionController } from "./fileTreeDragMention";
import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion";
import { buildFileTreePathUpdates } from "./fileTreePathReconciliation";
Expand All @@ -38,6 +39,13 @@ interface FileBrowserPanelProps {
/** Bumped when the same path should be revealed again (e.g. re-opened from search). */
selectedPathRevealId: number;
onOpenFile: (relativePath: string) => void;
/**
* Set when `cwd` is a host folder outside the workspace rather than the
* workspace root. Entries then open and mention as host paths. A host folder can
* be a home directory, so nothing walks it whole: there is no expand-all, and
* search filters the folders already loaded instead of indexing on the server.
*/
hostFolder?: boolean;
onRefreshSelectedFile?: () => void;
workspaceMutationId: string | null;
}
Expand Down Expand Up @@ -104,7 +112,10 @@ export default function FileBrowserPanel({
onOpenFile,
onRefreshSelectedFile,
workspaceMutationId,
hostFolder = false,
}: FileBrowserPanelProps) {
const panelPath = (relativePath: string) =>
hostFolder ? joinHostPath(cwd, relativePath) : relativePath;
const { resolvedTheme } = useTheme();
const composerRef = useComposerHandleContext();
const fileContextMenu = useFileContextMenu(environmentId);
Expand All @@ -119,7 +130,10 @@ export default function FileBrowserPanel({
} = useDirectoryEntries(environmentId, cwd);
const [query, setQuery] = useState("");
const [expandAll, setExpandAll] = useState(false);
const pathSearch = useProjectPathSearch({ environmentId, cwd, query: query.slice(0, 256) }, 200);
const pathSearch = useProjectPathSearch(
{ environmentId, cwd: hostFolder ? null : cwd, query: query.slice(0, 256) },
200,
);
const entries = useMemo(() => {
const result = new Map(directoryEntries.map((entry) => [entry.path, entry]));
if (query.trim() && !pathSearch.isPending) {
Expand Down Expand Up @@ -172,7 +186,7 @@ export default function FileBrowserPanel({
return;
}
const relativePath = item.path.replace(/\/$/, "");
const mention = serializeComposerFileLink(relativePath);
const mention = serializeComposerFileLink(panelPath(relativePath));
const pointer = contextMenuPointerRef.current;
const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000;
const anchorRect = context.anchorElement.getBoundingClientRect();
Expand Down Expand Up @@ -248,8 +262,9 @@ export default function FileBrowserPanel({
() =>
createFileTreeDragMentionController({
deselect: (path) => treeModelRef.current?.getItem(path)?.deselect(),
...(hostFolder ? { mentionPath: (path: string) => joinHostPath(cwd, path) } : {}),
}),
[],
[cwd, hostFolder],
);
const { model } = useFileTree({
composition: {
Expand Down Expand Up @@ -283,7 +298,7 @@ export default function FileBrowserPanel({
const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, "");
if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") {
treeSelectionPathRef.current = selectedPath;
onOpenFile(selectedPath);
onOpenFile(panelPath(selectedPath));
}
},
paths: [],
Expand Down Expand Up @@ -516,7 +531,7 @@ export default function FileBrowserPanel({
onValueChange={handleSearchValueChange}
onClose={closeSearch}
/>
{directoryPaths.length > 0 ? (
{directoryPaths.length > 0 && !hostFolder ? (
<Tooltip>
<TooltipTrigger
render={
Expand Down
Loading
Loading