Skip to content
Merged
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
30 changes: 30 additions & 0 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,36 @@ describe("AssetAccess", () => {
}).pipe(Effect.provide(layerTest)),
);

it.effect("serves home-relative draft media files from the home folder", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-draft-root-",
});
const home = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-draft-home-",
});
const clipPath = path.join(home, "Downloads", "clip.mp4");
yield* fileSystem.makeDirectory(path.dirname(clipPath), { recursive: true });
yield* fileSystem.writeFileString(clipPath, "video");
const canonicalClipPath = yield* fileSystem.realPath(clipPath);

const result = yield* issueAssetUrl({
resource: { _tag: "draft-workspace-file", cwd: root, path: "~/Downloads/clip.mp4" },
workspaceRoot: root,
}).pipe(Effect.provideService(HostProcess.HomeDirectory, home));
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const token = suffix.slice(0, suffix.indexOf("/"));

expect(yield* resolveAsset(token, "clip.mp4")).toMatchObject({
kind: "file",
path: canonicalClipPath,
mimeType: "video/mp4",
});
}).pipe(Effect.provide(layerTest)),
);

it.effect("serves absolute draft media files exactly, wherever they live", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
7 changes: 5 additions & 2 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,9 +553,12 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
}
case "draft-workspace-file": {
const draftWorkspaceRoot = input.workspaceRoot ?? input.resource.cwd;
if (path.isAbsolute(input.resource.path)) {
const hostPath = input.resource.path.startsWith("~/")
? expandHomePath(input.resource.path, yield* HostProcess.HomeDirectory)
: input.resource.path;
if (path.isAbsolute(hostPath)) {
const finalized = yield* finalizeAbsoluteMediaFileAsset({
requestedPath: input.resource.path,
requestedPath: hostPath,
resource: input.resource,
expiresAt,
});
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/workspace/WorkspaceFileSystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,27 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
}),
);

it.effect("reads ~/ paths from the server home outside the workspace", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const cwd = yield* makeTempDir;
const home = yield* makeTempDir;
yield* writeTextFile(home, ".claude/CLAUDE.md", "# Notes\n");
yield* writeTextFile(cwd, "~/.claude/CLAUDE.md", "Workspace decoy\n");

const result = yield* workspaceFileSystem
.readFile({ cwd, relativePath: "~/.claude/CLAUDE.md" })
.pipe(Effect.provideService(HostProcess.HomeDirectory, home));

expect(result).toEqual({
relativePath: "~/.claude/CLAUDE.md",
contents: "# Notes\n",
byteLength: 8,
truncated: false,
});
}),
);

it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
"rejects a FIFO without blocking on open",
() =>
Expand Down
15 changes: 9 additions & 6 deletions apps/server/src/workspace/WorkspaceFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,22 +221,25 @@ export const make = Effect.gen(function* () {

/**
* Workspace-relative paths must stay inside the root, symlinks included. An
* absolute path reads a host file in place, such as a report an agent wrote to a
* temp directory; it gets no root check.
* absolute or `~/` path reads a host file in place, such as a report an agent
* wrote to a temp directory; it gets no root check.
*/
const resolveReadTarget = Effect.fn("WorkspaceFileSystem.resolveReadTarget")(function* (
input: ProjectReadFileInput,
) {
const requestedPath = input.relativePath.trim();
if (path.isAbsolute(requestedPath)) {
const hostPath = requestedPath.startsWith("~/")
? expandHomePath(requestedPath, yield* HostProcess.HomeDirectory)
: requestedPath;
if (path.isAbsolute(hostPath)) {
const realTargetPath = yield* Effect.tryPromise({
try: () => NodeFSP.realpath(requestedPath),
try: () => NodeFSP.realpath(hostPath),
catch: (cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: requestedPath,
operationPath: requestedPath,
resolvedPath: hostPath,
operationPath: hostPath,
operation: "realpath-target",
cause,
}),
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
extractMarkdownLinkHrefs,
inlineCodeFilePathCandidate,
} from "@t3tools/shared/markdownLinks";
import { isAbsolutePath, resolveWorkspaceFilePath } from "@t3tools/shared/path";
import { isHostPath, resolveWorkspaceFilePath } from "@t3tools/shared/path";
import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts";
import {
Expand Down Expand Up @@ -2724,7 +2724,7 @@ function useChatMarkdownState({
) !== null;
const panelPath =
fileLinkMeta.workspaceRelativePath ??
(!canPreviewMedia && isAbsolutePath(fileLinkMeta.filePath) ? fileLinkMeta.filePath : null);
(!canPreviewMedia && isHostPath(fileLinkMeta.filePath) ? fileLinkMeta.filePath : null);

return (
<MarkdownFileLink
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/files/FileBreadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
import { useTheme } from "~/hooks/useTheme";
import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
import { cn } from "~/lib/utils";
import { isAbsolutePath } from "@t3tools/shared/path";
import { isHostPath } from "@t3tools/shared/path";

import {
breadcrumbPathOf,
Expand Down Expand Up @@ -300,7 +300,7 @@ function FolderBreadcrumb(props: {
* menu: the browser below already lists the folder it moves to.
*/
export function FileBreadcrumbs(props: FileBreadcrumbsProps) {
const hostPath = isAbsolutePath(props.relativePath);
const hostPath = isHostPath(props.relativePath);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const breadcrumbs = fileBreadcrumbs(props.projectName, props.trail);
const currentPath = breadcrumbPathOf(props.relativePath);
const currentIndex = breadcrumbs.findIndex((crumb) => crumb.path === currentPath);
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/files/FilePreviewPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ describe("shouldShowFileExplorer", () => {
attachmentOpen: false,
}),
).toBe(false);
expect(
shouldShowFileExplorer({
relativePath: "~/.claude/CLAUDE.md",
explorerOpen: true,
attachmentOpen: false,
}),
).toBe(false);
expect(
shouldShowFileExplorer({
relativePath: "report.pdf",
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import { resolveDiffThemeName } from "~/lib/diffRendering";
import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";
import { cn } from "~/lib/utils";
import type { ChatFileAttachment } from "~/types";
import { isAbsolutePath } from "@t3tools/shared/path";
import { isHostPath } from "@t3tools/shared/path";
import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
import { ScrollArea } from "~/components/ui/scroll-area";
import { stackedThreadToast, toastManager } from "~/components/ui/toast";
Expand Down Expand Up @@ -1015,7 +1015,7 @@ export default function FilePreviewPanel({
const isPdf = relativePath !== null && isPdfPreviewFile(relativePath);
const isHtml = relativePath !== null && !isPdf && isBrowserPreviewFile(relativePath);
const isHostFile =
attachment !== undefined || (relativePath !== null && isAbsolutePath(relativePath));
attachment !== undefined || (relativePath !== null && isHostPath(relativePath));
Comment thread
juliusmarminge marked this conversation as resolved.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const fileAccess = useFilesystemReadAccess(environmentId);
const { canReadFiles } = fileAccess;
const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope);
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/components/files/filePath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ describe("fileBreadcrumbs", () => {
"\\\\server\\share\\report.md",
]);
});

it("builds home-relative breadcrumbs with navigable child folders", () => {
expect(fileBreadcrumbs("t3code", "~/.claude/CLAUDE.md")).toEqual([
{ label: "~", path: "~", kind: "directory" },
{ label: ".claude", path: "~/.claude", kind: "directory" },
{ label: "CLAUDE.md", path: "~/.claude/CLAUDE.md", kind: "file" },
]);
expect(isListableHostFolder("~/.claude")).toBe(true);
});
});

describe("breadcrumbPathOf", () => {
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/files/filePath.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ProjectEntry } from "@t3tools/contracts";
import { isWindowsAbsolutePath, isAbsolutePath } from "@t3tools/shared/path";
import { isWindowsAbsolutePath, isHostPath } from "@t3tools/shared/path";

export interface FileBreadcrumb {
label: string;
Expand All @@ -12,10 +12,10 @@ export interface FileBreadcrumbChild extends ProjectEntry {
}

export function fileBreadcrumbs(projectName: string, relativePath: string): FileBreadcrumb[] {
const hostPath = isAbsolutePath(relativePath);
const hostPath = isHostPath(relativePath);
const separator = isWindowsAbsolutePath(relativePath) ? "\\" : "/";
const parts = relativePath.split(/[\\/]/).filter(Boolean);
const root = relativePath.startsWith("\\\\") ? "\\\\" : hostPath && separator === "/" ? "/" : "";
const root = relativePath.startsWith("\\\\") ? "\\\\" : relativePath.startsWith("/") ? "/" : "";
return [
...(hostPath ? [] : [{ label: projectName, path: "", kind: "project" as const }]),
...parts.map((part, index) => {
Expand Down Expand Up @@ -43,7 +43,7 @@ export function breadcrumbPathOf(relativePath: string): string {
* server (`\\server`) is not one; its shares (`\\server\share`) are.
*/
export function isListableHostFolder(path: string): boolean {
return isAbsolutePath(path) && !/^\\\\[^\\/]+$/.test(path);
return isHostPath(path) && !/^\\\\[^\\/]+$/.test(path);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/** Index of `path`'s crumb among `trail`'s crumbs; -1 when the trail does not pass through it. */
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/files/filePreviewMode.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AssetResource, ProjectReadFileError, ScopedThreadRef } from "@t3tools/contracts";
import { isAbsolutePath, workspaceRelativeFilePath } from "@t3tools/shared/path";
import { isHostPath, workspaceRelativeFilePath } from "@t3tools/shared/path";

export function resolveFilePreviewPath(path: string | null, cwd: string): string | null {
if (path === null) return null;
Expand Down Expand Up @@ -32,7 +32,7 @@ export function shouldShowFileExplorer(input: {
readonly explorerOpen: boolean;
readonly attachmentOpen: boolean;
}): boolean {
if (input.attachmentOpen || (input.relativePath && isAbsolutePath(input.relativePath))) {
if (input.attachmentOpen || (input.relativePath && isHostPath(input.relativePath))) {
return false;
}
return input.explorerOpen || input.relativePath === null;
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ export function isAbsolutePath(value: string): boolean {
return value.startsWith("/") || isWindowsAbsolutePath(value);
}

/** Home-relative host paths are expanded by the destination server. */
export function isHostPath(value: string): boolean {
return isAbsolutePath(value) || value.startsWith("~/");
}

export function resolveWorkspaceFilePath(path: string, workspaceRoot: string): string {
if (isAbsolutePath(path) || path.startsWith("~/") || path.startsWith("~\\")) return path;
const base = workspaceRoot.replace(/[/\\]+$/, "");
Expand Down
Loading