Support open file in Finder from chat content
This commit is contained in:
parent
1ccff68520
commit
724be0bb81
80
apps/web/app/api/workspace/open-file/route.ts
Normal file
80
apps/web/app/api/workspace/open-file/route.ts
Normal 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 }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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>;
|
||||
},
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user