Support open file in Finder from chat content

This commit is contained in:
Mark 2026-02-14 14:29:46 -08:00
parent 1ccff68520
commit 724be0bb81
3 changed files with 234 additions and 3 deletions

View File

@ -0,0 +1,80 @@
import { exec } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve, normalize } from "node:path";
import { homedir } from "node:os";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* POST /api/workspace/open-file
* Opens a file or directory using the system's default application.
* On macOS this uses `open`, on Linux `xdg-open`.
*/
export async function POST(req: Request) {
let body: { path?: string; reveal?: boolean };
try {
body = await req.json();
} catch {
return Response.json(
{ error: "Invalid JSON body" },
{ status: 400 },
);
}
const rawPath = body.path;
if (!rawPath || typeof rawPath !== "string") {
return Response.json(
{ error: "Missing 'path' in request body" },
{ status: 400 },
);
}
// Expand ~ to home directory
const expanded = rawPath.startsWith("~/")
? rawPath.replace(/^~/, homedir())
: rawPath;
const resolved = resolve(normalize(expanded));
if (!existsSync(resolved)) {
return Response.json(
{ error: "File not found", path: resolved },
{ status: 404 },
);
}
const platform = process.platform;
const reveal = body.reveal === true;
let cmd: string;
if (platform === "darwin") {
// macOS: use `open` — `-R` reveals in Finder instead of opening
cmd = reveal
? `open -R ${JSON.stringify(resolved)}`
: `open ${JSON.stringify(resolved)}`;
} else if (platform === "linux") {
// Linux: xdg-open (no reveal equivalent)
cmd = `xdg-open ${JSON.stringify(resolved)}`;
} else {
return Response.json(
{ error: `Unsupported platform: ${platform}` },
{ status: 400 },
);
}
return new Promise<Response>((res) => {
exec(cmd, (error) => {
if (error) {
res(
Response.json(
{ error: `Failed to open file: ${error.message}` },
{ status: 500 },
),
);
} else {
res(Response.json({ ok: true, path: resolved }));
}
});
});
}

View File

@ -2,7 +2,7 @@
import dynamic from "next/dynamic";
import type { UIMessage } from "ai";
import { memo } from "react";
import { memo, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
@ -420,6 +420,120 @@ function AttachedFilesCard({ paths }: { paths: string[] }) {
);
}
/* ─── File path detection for clickable inline code ─── */
/**
* Detect whether an inline code string looks like a local file/directory path.
* Matches patterns like:
* ~/Downloads/file.pdf
* /Users/name/Documents/file.txt
* /home/user/file.py
* ./relative/path
* ../parent/path
* /etc/config
*/
const FILE_PATH_RE =
/^(?:~\/|\.\.?\/|\/(?:Users|home|tmp|var|etc|opt|usr|Library|Applications|Downloads|Documents|Desktop)\b)[^\s]*$/;
function looksLikeFilePath(text: string): boolean {
if (!text || text.length < 2 || text.length > 500) {return false;}
return FILE_PATH_RE.test(text.trim());
}
/** Open a file path using the system default application. */
async function openFilePath(path: string, reveal = false) {
try {
const res = await fetch("/api/workspace/open-file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, reveal }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
console.error("Failed to open file:", data);
}
} catch (err) {
console.error("Failed to open file:", err);
}
}
/** Clickable file path inline code element */
function FilePathCode({
path,
children,
}: {
path: string;
children: React.ReactNode;
}) {
const [status, setStatus] = useState<"idle" | "opening" | "error">("idle");
const handleClick = async (e: React.MouseEvent) => {
e.preventDefault();
setStatus("opening");
try {
const res = await fetch("/api/workspace/open-file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
if (!res.ok) {
setStatus("error");
setTimeout(() => setStatus("idle"), 2000);
} else {
setStatus("idle");
}
} catch {
setStatus("error");
setTimeout(() => setStatus("idle"), 2000);
}
};
const handleContextMenu = async (e: React.MouseEvent) => {
// Right-click reveals in Finder instead of opening
e.preventDefault();
await openFilePath(path, true);
};
return (
<code
className="file-path-code"
onClick={handleClick}
onContextMenu={handleContextMenu}
title={status === "error" ? "File not found" : `Click to open · Right-click to reveal in Finder`}
style={{
cursor: status === "opening" ? "wait" : "pointer",
opacity: status === "opening" ? 0.7 : 1,
}}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="file-path-icon"
>
{status === "error" ? (
<>
<circle cx="12" cy="12" r="10" />
<line x1="15" x2="9" y1="9" y2="15" />
<line x1="9" x2="15" y1="9" y2="15" />
</>
) : (
<>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</>
)}
</svg>
{children}
</code>
);
}
/* ─── Markdown component overrides for chat ─── */
const mdComponents: Components = {
@ -491,7 +605,7 @@ const mdComponents: Components = {
// Fallback: default pre rendering
return <pre {...props}>{children}</pre>;
},
// Inline code (no highlighting needed)
// Inline code — detect file paths and make them clickable
code: ({ children, className, ...props }) => {
// If this code has a language class, it's inside a <pre> and
// will be handled by the pre override above. Just return raw.
@ -502,7 +616,14 @@ const mdComponents: Components = {
</code>
);
}
// Inline code
// Check if the inline code content looks like a file path
const text = typeof children === "string" ? children : "";
if (text && looksLikeFilePath(text)) {
return <FilePathCode path={text}>{children}</FilePathCode>;
}
// Regular inline code
return <code {...props}>{children}</code>;
},
};

View File

@ -980,6 +980,36 @@ a,
padding: 0.15em 0.35em;
}
/* Clickable file path inline code */
.chat-prose code.file-path-code {
display: inline-flex;
align-items: center;
gap: 0.2em;
padding: 0em 0.3em;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
color: var(--color-accent);
border: 1px solid var(--color-border);
background: rgba(255, 255, 255, 0.2);
text-decoration: none;
}
.chat-prose code.file-path-code:hover {
background: rgba(255, 255, 255, 0.4);
}
.chat-prose code.file-path-code:active {
background: rgba(255, 255, 255, 1);
}
.chat-prose code.file-path-code .file-path-icon {
flex-shrink: 0;
opacity: 0.6;
}
.chat-prose pre {
background: var(--color-surface);
border: 1px solid var(--color-border);