diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index bcab6a2f25..a0c1cbce86 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -148,6 +148,45 @@ const config: Configuration = { schemes: ["superset"], }, + // File associations so Finder / Explorer show Superset in "Open with" and + // OS drag-to-dock routes the path through app.on("open-file") / argv. + // + // v1 scope is intentionally narrow so we don't hijack system defaults for + // popular formats. Users can still drop any file via window DnD — this + // list only controls the "Open with" menu. `role: "Editor"` with + // `rank: "Alternate"` keeps us off the default-handler slot on macOS; the + // user has to pick Superset explicitly. + // + // IMPORTANT: The Linux AppImage FileAssociation parser only accepts a + // single-string `ext` (array fails with "expects \" or n, but found ["), + // so each extension gets its own entry. macOS / Windows tolerate the same + // shape, so we use one form everywhere for parity. + // + // Note on extensionless files (.env, .gitignore, Dockerfile): electron- + // builder's `ext` only maps real extensions, so those can't be registered + // here. Window DnD is the supported path for those. + fileAssociations: [ + "md", + "markdown", + "txt", + "log", + "ts", + "tsx", + "js", + "jsx", + "mjs", + "cjs", + "py", + "sh", + "bash", + "zsh", + ].map((ext) => ({ + ext, + name: "Text File", + role: "Editor", + rank: "Alternate", + })), + // Linux linux: { ...(existsSync(linuxIconPath) ? { icon: linuxIconPath } : {}), diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 334cfdca45..b307d2ab1a 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -35,6 +35,7 @@ import { createProjectsRouter } from "./projects"; import { createReferenceGraphRouter } from "./reference-graph"; import { createResourceMetricsRouter } from "./resource-metrics"; import { createRingtoneRouter } from "./ringtone"; +import { createScratchRouter } from "./scratch"; import { createServiceStatusRouter } from "./service-status"; import { createSettingsRouter } from "./settings"; import { createTabTearoffRouter } from "./tab-tearoff"; @@ -73,6 +74,7 @@ export const createAppRouter = ( permissions: createPermissionsRouter(), ports: createPortsRouter(), resourceMetrics: createResourceMetricsRouter(), + scratch: createScratchRouter(), menu: createMenuRouter(), languageServices: createLanguageServicesRouter(), referenceGraph: createReferenceGraphRouter(), diff --git a/apps/desktop/src/lib/trpc/routers/scratch/index.ts b/apps/desktop/src/lib/trpc/routers/scratch/index.ts new file mode 100644 index 0000000000..b6d49615e0 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/scratch/index.ts @@ -0,0 +1,251 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { TRPCError } from "@trpc/server"; +import { observable } from "@trpc/server/observable"; +import { + dispatchPaths, + type FileIntakeScratchBatch, + type FileIntakeWorkspaceBatch, + fileIntakeEmitter, +} from "main/lib/file-intake"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +// v1 scratch file procedures: deliberately do NOT go through the +// workspace-scoped filesystem service. A scratch file has no workspace root — +// the path the user handed us (via DnD / open-with / argv) IS the access +// boundary. We still sanity-check the path is absolute and does not traverse +// to /etc or similar via parent refs after resolution. +const MAX_SCRATCH_READ_BYTES = 5 * 1024 * 1024; // 5 MB + +/** Paths that aren't strictly off-limits but where an accidental DnD edit / + * viewing would be much worse than helpful. scratch mode is a text-file + * convenience feature; it is not a general system editor. + * + * Patterns are evaluated against the **forward-slash-normalized** path so + * the same regexes catch Windows paths (`C:/Users/x/.ssh/id_rsa`) without + * duplicating every rule for backslashes. + */ +const SCRATCH_DENY_PATTERNS: RegExp[] = [ + // Unix system dirs. + /^\/etc\//, + /^\/System\//, + /^\/usr\//, + /^\/private\/etc\//, + // Windows system dirs (path has been forward-slashed beforehand). + /^[A-Za-z]:\/Windows\//, + /^[A-Za-z]:\/Program(Data| Files)\//, + // User secrets — match the dotfolder segment on any platform. + /\/\.ssh\//, + /\/\.aws\//, + /\/\.gnupg\//, +]; + +function sanitizeAbsolutePath(input: string): string { + if (!path.isAbsolute(input)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Scratch paths must be absolute", + }); + } + // path.resolve normalizes `..` / `.` segments so the result can be compared + // against a prefix safely if we ever add a sandbox root later. + return path.resolve(input); +} + +function assertScratchAllowed(abs: string, action: "read" | "write"): void { + // Normalize separators so POSIX patterns also match Windows paths. + const probe = abs.replace(/\\/g, "/"); + for (const pattern of SCRATCH_DENY_PATTERNS) { + if (pattern.test(probe)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `Scratch ${action} refused for system/secret path: ${abs}`, + }); + } + } +} + +/** Resolve the parent directory via realpath and rejoin basename. Catches + * symlink-parent escapes where the final path component looks fine but a + * parent segment redirects into a protected tree. */ +async function canonicalizeLeafPath(abs: string): Promise { + const dir = path.dirname(abs); + let canonicalDir: string; + try { + canonicalDir = await fs.realpath(dir); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === "ENOENT") { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Parent directory does not exist: ${dir}`, + }); + } + throw err; + } + return path.join(canonicalDir, path.basename(abs)); +} + +export const createScratchRouter = () => + router({ + readFile: publicProcedure + .input( + z.object({ + absolutePath: z.string(), + maxBytes: z.number().int().positive().optional(), + }), + ) + .query(async ({ input }) => { + const abs = sanitizeAbsolutePath(input.absolutePath); + const canonical = await canonicalizeLeafPath(abs); + // Symmetric with writeFile: deny readable secrets too so the user + // doesn't get a surprise `FORBIDDEN` only at save time after + // editing `~/.ssh/config` in scratch. + assertScratchAllowed(canonical, "read"); + const maxBytes = Math.min( + input.maxBytes ?? MAX_SCRATCH_READ_BYTES, + MAX_SCRATCH_READ_BYTES, + ); + + let lstat: Awaited>; + try { + lstat = await fs.lstat(canonical); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === "ENOENT") { + throw new TRPCError({ + code: "NOT_FOUND", + message: `File not found: ${canonical}`, + }); + } + throw err; + } + if (lstat.isDirectory()) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Path is a directory: ${canonical}`, + }); + } + if (lstat.isSymbolicLink()) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `Refusing to read through symlink: ${canonical}`, + }); + } + if (lstat.size > maxBytes) { + return { + kind: "too-large" as const, + absolutePath: canonical, + size: lstat.size, + maxBytes, + }; + } + + // Read as UTF-8 text. For true binary files this will still return + // characters but the CodeEditor in the renderer renders it as-is. + // Scratch mode is intended for text files; binary support is not a + // v1 goal. + const content = await fs.readFile(canonical, { encoding: "utf8" }); + return { + kind: "text" as const, + absolutePath: canonical, + content, + size: lstat.size, + mtimeMs: lstat.mtimeMs, + }; + }), + + writeFile: publicProcedure + .input( + z.object({ + absolutePath: z.string(), + content: z.string(), + }), + ) + .mutation(async ({ input }) => { + const abs = sanitizeAbsolutePath(input.absolutePath); + // Resolve symlinks in every parent segment before enforcing + // policy. Checking only the final basename with lstat(abs) misses + // the case where a *parent* directory is a symlink pointing into + // a protected tree — lstat sees a regular file and the deny-list + // sees `/tmp/link/...` but writeFile then touches the real + // target. canonicalizeLeafPath + assertScratchAllowed catch both + // parent-dir escapes and direct hits. + const canonical = await canonicalizeLeafPath(abs); + assertScratchAllowed(canonical, "write"); + + let lstat: Awaited> | null = null; + try { + lstat = await fs.lstat(canonical); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw err; + } + } + if (lstat?.isDirectory()) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Path is a directory: ${canonical}`, + }); + } + if (lstat?.isSymbolicLink()) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `Refusing to write through symlink: ${canonical}`, + }); + } + + await fs.writeFile(canonical, input.content, { encoding: "utf8" }); + const newStat = await fs.stat(canonical); + return { + absolutePath: canonical, + size: newStat.size, + mtimeMs: newStat.mtimeMs, + }; + }), + + /** + * Renderer-originated DnD: when the user drops OS files onto the window, + * the preload surfaces the absolute paths and calls this mutation. We + * route through the same `dispatchPaths` used by native `open-file` / + * argv so the classification + navigation stay in one place. + */ + ingestDroppedPaths: publicProcedure + .input( + z.object({ + absolutePaths: z.array(z.string()), + }), + ) + .mutation(async ({ input }) => { + const sanitized = input.absolutePaths + .filter((p) => path.isAbsolute(p)) + .map((p) => path.resolve(p)); + await dispatchPaths(sanitized); + return { accepted: sanitized.length }; + }), + + /** + * Subscriptions the renderer uses to receive file-intake dispatches. + * trpc-electron requires observables (not async generators) — we just + * mirror events from `fileIntakeEmitter`. AGENTS.md mandates tRPC for + * main↔renderer IPC; this replaces an earlier `webContents.send` path. + */ + onOpenWorkspaceBatch: publicProcedure.subscription(() => + observable((emit) => { + const handler = (batch: FileIntakeWorkspaceBatch) => emit.next(batch); + fileIntakeEmitter.on("open-workspace-batch", handler); + return () => { + fileIntakeEmitter.off("open-workspace-batch", handler); + }; + }), + ), + + onOpenScratchBatch: publicProcedure.subscription(() => + observable((emit) => { + const handler = (batch: FileIntakeScratchBatch) => emit.next(batch); + fileIntakeEmitter.on("open-scratch-batch", handler); + return () => { + fileIntakeEmitter.off("open-scratch-batch", handler); + }; + }), + ), + }); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 6fa586e9c1..a6f2fcf774 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -37,6 +37,13 @@ import { setWorkspaceDockIcon } from "./lib/dock-icon"; import { loadWebviewBrowserExtension } from "./lib/extensions"; import { createExtensionIconProtocolHandler } from "./lib/extensions/extension-icon-protocol"; import { loadInstalledExtensions } from "./lib/extensions/extension-manager"; +import { + dispatchPaths as dispatchFileIntakePaths, + drainPendingPaths as drainFileIntakePending, + filterFilePathArgs as filterFileIntakeArgs, + markFileIntakeReady, + queuePath as queueFileIntakePath, +} from "./lib/file-intake"; // FORK NOTE: upstream renamed host-service-manager → host-service-coordinator (#3250 relay) // Aliased as getHostServiceManager to minimize diff with fork's quit lifecycle code import { getHostServiceCoordinator as getHostServiceManager } from "./lib/host-service-coordinator"; @@ -349,6 +356,23 @@ app.on("open-url", async (event, url) => { } }); +// macOS fires `open-file` when a file is double-clicked with this app as the +// handler, dragged onto the dock icon, or passed via Finder "Open with". The +// event can arrive *before* app.whenReady() resolves on cold start — Electron +// recommends installing the listener inside `will-finish-launching` so we +// catch those early events. Paths arriving before the renderer is ready are +// queued in file-intake and drained later. +app.on("will-finish-launching", () => { + app.on("open-file", (event, filePath) => { + event.preventDefault(); + if (appReady) { + void dispatchFileIntakePaths([filePath]); + } else { + queueFileIntakePath(filePath); + } + }); +}); + export type QuitMode = "release" | "stop"; let pendingQuitMode: QuitMode | null = null; let isQuitting = false; @@ -605,13 +629,20 @@ const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { app.exit(0); } else { - // Windows/Linux: protocol URL arrives as argv on the second instance + // Windows/Linux: protocol URL arrives as argv on the second instance. + // File paths (from Explorer "Open with" / drag onto dock / CLI invocation) + // also land here — we hand those to the file-intake pipeline so the same + // classification logic runs regardless of platform. app.on("second-instance", async (_event, argv) => { focusMainWindow(); const url = findDeepLinkInArgv(argv); if (url) { await processDeepLink(url); } + const paths = await filterFileIntakeArgs(argv); + if (paths.length > 0) { + await dispatchFileIntakePaths(paths); + } }); (async () => { @@ -823,5 +854,57 @@ if (!gotTheLock) { } appReady = true; + + // File-intake waits for the renderer JS to actually load before + // declaring itself ready. Sending IPC to a freshly-created BrowserWindow + // whose renderer hasn't run yet would be silently dropped — the + // did-finish-load event is the first moment `ipcRenderer.on(...)` in + // the renderer has had a chance to register. + let coldStartDone = false; + const runColdStart = async () => { + if (coldStartDone) return; + coldStartDone = true; + markFileIntakeReady(); + try { + const coldStartPaths = await filterFileIntakeArgs(process.argv); + if (coldStartPaths.length > 0) { + await dispatchFileIntakePaths(coldStartPaths); + } + await drainFileIntakePending(); + } catch (err) { + console.error("[main] file-intake cold-start drain failed:", err); + } + }; + + const drainOnWindowReady = (win: BrowserWindow) => { + // macOS: closing all windows doesn't quit the app, and `open-file` / + // dock drops arriving during the no-windows interval are queued by + // dispatchPaths(). When the user re-activates (Dock click creates a + // fresh window), we drain here so those queued paths actually open + // instead of disappearing until next full restart. + const drain = () => { + if (!coldStartDone) { + void runColdStart(); + } else { + void drainFileIntakePending().catch((err) => { + console.error("[main] file-intake re-drain failed:", err); + }); + } + }; + if (win.webContents.isLoading()) { + win.webContents.once("did-finish-load", drain); + } else { + drain(); + } + }; + + const firstWindow = BrowserWindow.getAllWindows()[0]; + if (firstWindow) drainOnWindowReady(firstWindow); + // Re-drain whenever a new BrowserWindow appears (reactivate after + // close-all, tearoffs, etc.). Cold-start guard above ensures the + // initial sequence runs exactly once. + app.on("browser-window-created", (_ev, win) => { + drainOnWindowReady(win); + }); })(); } diff --git a/apps/desktop/src/main/lib/file-intake/index.ts b/apps/desktop/src/main/lib/file-intake/index.ts new file mode 100644 index 0000000000..c0ef44158d --- /dev/null +++ b/apps/desktop/src/main/lib/file-intake/index.ts @@ -0,0 +1,334 @@ +import { EventEmitter } from "node:events"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { projects, workspaces, worktrees } from "@superset/local-db"; +import { and, eq, isNull } from "drizzle-orm"; +import { BrowserWindow } from "electron"; +import { localDb } from "main/lib/local-db"; + +export interface FileIntakeWorkspaceBatch { + workspaceId: string; + absolutePaths: string[]; +} + +export interface FileIntakeScratchBatch { + absolutePaths: string[]; +} + +interface FileIntakeEvents { + "open-workspace-batch": [FileIntakeWorkspaceBatch]; + "open-scratch-batch": [FileIntakeScratchBatch]; +} + +/** + * Fan-out point for file-intake dispatch results. tRPC subscriptions in the + * scratch router feed off this emitter, turning each batch into an observable + * event the renderer can consume via `electronTrpc.scratch.*`. Using an + * emitter (instead of `webContents.send` directly) keeps IPC centralized in + * the tRPC layer per AGENTS.md's "always use tRPC for IPC" rule. + */ +export const fileIntakeEmitter = new EventEmitter(); + +export type FileIntakeTarget = + | { + kind: "workspace-file"; + workspaceId: string; + absolutePath: string; + isDirectory: boolean; + } + | { + kind: "scratch-file"; + absolutePath: string; + } + | { + kind: "scratch-directory"; + // v1: directory not in a registered workspace is treated as scratch: + // the directory itself is opened in the tabs as a "folder entry" (we just + // show the first file encountered). We keep the UX simple and do not + // recursively open — the user can register the folder as a workspace + // explicitly from the existing UI if they want full treatment. + absolutePath: string; + }; + +interface ResolvedWorkspace { + workspaceId: string; + worktreePath: string; +} + +const IS_WINDOWS = process.platform === "win32"; + +/** Make paths directly comparable across drops and DB rows. On Windows this + * also lowercases so drive-letter and directory case mismatches don't + * misclassify a registered-workspace file as scratch. */ +function normalize(p: string): string { + const resolved = path.resolve(p); + return IS_WINDOWS ? resolved.toLowerCase() : resolved; +} + +/** Walk symlinks before comparing so a file living outside its worktree via + * a symlink farm still classifies against the workspace it logically + * belongs to. Falls back to `path.resolve` if the target doesn't exist. */ +async function canonicalPath(p: string): Promise { + try { + return normalize(await fs.realpath(p)); + } catch { + return normalize(p); + } +} + +function isPathInside(child: string, parent: string): boolean { + const rel = path.relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); +} + +/** + * Scan the local DB for all workspace worktree/repo roots, long side first so + * nested paths win the match (e.g., a submodule registered as a workspace + * beats its parent repo). + */ +function listRegisteredRoots(): ResolvedWorkspace[] { + const worktreeRoots = localDb + .select({ + workspaceId: workspaces.id, + worktreePath: worktrees.path, + }) + .from(workspaces) + .innerJoin(worktrees, eq(workspaces.worktreeId, worktrees.id)) + .where(and(eq(workspaces.type, "worktree"), isNull(workspaces.deletingAt))) + .all() + .filter( + (row): row is ResolvedWorkspace => + typeof row.worktreePath === "string" && row.worktreePath.length > 0, + ); + + const branchRoots = localDb + .select({ + workspaceId: workspaces.id, + worktreePath: projects.mainRepoPath, + }) + .from(workspaces) + .innerJoin(projects, eq(workspaces.projectId, projects.id)) + .where(and(eq(workspaces.type, "branch"), isNull(workspaces.deletingAt))) + .all() + .filter( + (row): row is ResolvedWorkspace => + typeof row.worktreePath === "string" && row.worktreePath.length > 0, + ); + + return [...worktreeRoots, ...branchRoots].sort( + (a, b) => b.worktreePath.length - a.worktreePath.length, + ); +} + +async function resolveRegisteredRoots(): Promise { + const raw = listRegisteredRoots(); + return Promise.all( + raw.map(async (root) => ({ + workspaceId: root.workspaceId, + worktreePath: await canonicalPath(root.worktreePath), + })), + ); +} + +function findRegisteredWorkspaceFor( + canonicalChild: string, + roots: ResolvedWorkspace[], +): ResolvedWorkspace | null { + for (const root of roots) { + if (isPathInside(canonicalChild, root.worktreePath)) { + return root; + } + } + return null; +} + +async function classifyPath( + absolutePath: string, + roots: ResolvedWorkspace[], +): Promise { + let stat: import("node:fs").Stats; + try { + stat = await fs.stat(absolutePath); + } catch { + return { kind: "scratch-file", absolutePath }; + } + const isDirectory = stat.isDirectory(); + + const canonical = await canonicalPath(absolutePath); + const match = findRegisteredWorkspaceFor(canonical, roots); + if (match) { + return { + kind: "workspace-file", + workspaceId: match.workspaceId, + absolutePath, + isDirectory, + }; + } + + if (isDirectory) { + return { kind: "scratch-directory", absolutePath }; + } + return { kind: "scratch-file", absolutePath }; +} + +export async function classifyPaths( + absolutePaths: string[], +): Promise { + const deduped = Array.from( + new Map(absolutePaths.map((p) => [normalize(p), p])).values(), + ); + // Resolve registered roots once per batch — previously each classifyPath + // ran two SQLite joins independently, which scaled badly with large drops. + const roots = await resolveRegisteredRoots(); + return Promise.all(deduped.map((p) => classifyPath(p, roots))); +} + +/** + * Split targets by kind so the renderer can be instructed how to open each. + * + * Directories inside registered workspaces are kept in `byWorkspace` as an + * entry with an empty `absolutePaths` when they're the only drop for that + * workspace — that way the renderer still navigates to the workspace but + * doesn't try to open the folder as a file (which would produce a broken + * FileViewerPane). + */ +function splitTargets(targets: FileIntakeTarget[]) { + const byWorkspace = new Map(); + const scratch: string[] = []; + for (const t of targets) { + if (t.kind === "workspace-file") { + const existing = byWorkspace.get(t.workspaceId) ?? []; + // Directories: register the workspace as a nav target but never push + // the path as a file to open. + if (!t.isDirectory) existing.push(t.absolutePath); + byWorkspace.set(t.workspaceId, existing); + } else { + // scratch-file and scratch-directory both surface as scratch tabs; + // v1 scratch doesn't distinguish, it just opens the path. + scratch.push(t.absolutePath); + } + } + return { byWorkspace, scratch }; +} + +export function focusFirstWindow(): BrowserWindow | null { + const wins = BrowserWindow.getAllWindows(); + if (wins.length === 0) return null; + const main = wins[0]; + if (main.isMinimized()) main.restore(); + main.show(); + main.focus(); + return main; +} + +/** + * Pending cold-start paths: macOS fires `open-file` before the window exists, + * and on Windows/Linux the first launch's file args arrive in process.argv + * before the renderer is ready. We queue here and drain on first dispatch. + */ +const pendingPaths: string[] = []; +let ready = false; + +export function queuePath(absolutePath: string): void { + pendingPaths.push(absolutePath); +} + +export function markFileIntakeReady(): void { + ready = true; +} + +export function isFileIntakeReady(): boolean { + return ready; +} + +export function takePendingPaths(): string[] { + const snapshot = pendingPaths.splice(0, pendingPaths.length); + return snapshot; +} + +/** + * Public API: hand a batch of OS paths (from open-file / argv / DnD / + * second-instance) over to the renderer. + * + * We *never* encode file paths in the route URL. The renderer's persistent + * hash history serializes routes to localStorage, which would silently + * restore scratch / workspace file intents on the next launch (Q1:B violation) + * and log absolute paths to disk. Payloads instead fan out via + * `fileIntakeEmitter`, which the scratch tRPC subscriptions surface to the + * renderer as observable events — keeping IPC inside the tRPC layer. + */ +export async function dispatchPaths(absolutePaths: string[]): Promise { + if (absolutePaths.length === 0) return; + + if (!ready) { + for (const p of absolutePaths) queuePath(p); + return; + } + + const targets = await classifyPaths(absolutePaths); + if (targets.length === 0) return; + + const win = focusFirstWindow(); + if (!win) { + for (const p of absolutePaths) queuePath(p); + return; + } + + const { byWorkspace, scratch } = splitTargets(targets); + + // Q5:A — emit per-workspace so the renderer can open all files as tabs + // without the DeepLinkNavigation intent being overwritten between events. + // Directory-only entries arrive as empty paths; the renderer still + // navigates so Q2:A (folder drop → switch to workspace) works. + for (const [workspaceId, paths] of byWorkspace) { + fileIntakeEmitter.emit("open-workspace-batch", { + workspaceId, + absolutePaths: paths, + }); + } + + if (scratch.length > 0) { + fileIntakeEmitter.emit("open-scratch-batch", { + absolutePaths: scratch, + }); + } +} + +export async function drainPendingPaths(): Promise { + if (pendingPaths.length === 0) return; + const paths = takePendingPaths(); + await dispatchPaths(paths); +} + +/** + * Identify file-path-looking argv entries. We deliberately skip flags, URLs, + * and the executable path so we don't misinterpret the launch argv on cold + * start. Only entries that already exist on disk (as either file or directory) + * are treated as drops. + */ +export async function filterFilePathArgs(argv: string[]): Promise { + // In dev the Electron binary runs with the main script as argv[1] + // (`electron /path/to/main.js ...`); packaged builds jump straight to + // user args at argv[1]. Using `process.defaultApp` mirrors the same + // detection used in setAsDefaultProtocolClient at main/index.ts. + const userArgsStart = process.defaultApp ? 2 : 1; + const candidates = argv.filter((arg, idx) => { + if (idx < userArgsStart) return false; + if (arg.startsWith("-")) return false; + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(arg)) return false; + return true; + }); + + const resolved = await Promise.all( + candidates.map(async (arg) => { + try { + const abs = path.isAbsolute(arg) ? arg : path.resolve(arg); + await fs.access(abs); + return abs; + } catch { + return null; + } + }), + ); + return resolved.filter((v): v is string => v !== null); +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 0e3a3baf7e..6428f874d5 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -5,13 +5,17 @@ import { exposeElectronTRPC } from "trpc-electron/main"; declare const __APP_VERSION__: string; +// Expose via `typeof` so downstream interface augmentations in the renderer +// and tests don't re-declare the shape with different modifiers (TS2687). +const webUtilsAPI = { + getPathForFile: (file: File) => webUtils.getPathForFile(file), +}; + declare global { interface Window { App: typeof API; ipcRenderer: typeof ipcRendererAPI; - webUtils: { - getPathForFile: (file: File) => string; - }; + webUtils: typeof webUtilsAPI; } } @@ -82,6 +86,4 @@ exposeElectronTRPC(); contextBridge.exposeInMainWorld("App", API); contextBridge.exposeInMainWorld("ipcRenderer", ipcRendererAPI); -contextBridge.exposeInMainWorld("webUtils", { - getPathForFile: (file: File) => webUtils.getPathForFile(file), -}); +contextBridge.exposeInMainWorld("webUtils", webUtilsAPI); diff --git a/apps/desktop/src/renderer/index.tsx b/apps/desktop/src/renderer/index.tsx index f222a13979..f4abb4630c 100644 --- a/apps/desktop/src/renderer/index.tsx +++ b/apps/desktop/src/renderer/index.tsx @@ -12,6 +12,7 @@ import { markBootMounted, reportBootError, } from "./lib/boot-errors"; +import { installFileIntakeClient } from "./lib/file-intake-client"; import { persistentHistory } from "./lib/persistent-hash-history"; import { posthog } from "./lib/posthog"; import { electronQueryClient } from "./providers/ElectronTRPCProvider"; @@ -117,12 +118,31 @@ if (ipcRenderer) { ); } +// File intake: OS-level DnD onto the window + IPC batches from main for +// drops that span registered + unregistered paths. +// +// Tearoff windows (detached panes) share the same renderer bundle. Installing +// the file-intake client there would make every tearoff subscribe to the +// workspace/scratch batch events and try to navigate its own router — but +// tearoffs aren't full app shells and don't own the workspace route tree. +// Scope the install to the main window only. +import { isTearoffWindow } from "./hooks/useTearoffInit"; + +const teardownFileIntake = isTearoffWindow() + ? () => {} + : installFileIntakeClient({ + navigate: (opts) => { + router.navigate(opts as Parameters[0]); + }, + }); + if (import.meta.hot) { import.meta.hot.dispose(() => { unsubscribe(); if (ipcRenderer) { ipcRenderer.off("deep-link-navigate", handleDeepLink); } + teardownFileIntake(); cleanupBootErrorHandling(); }); } diff --git a/apps/desktop/src/renderer/lib/file-intake-client/index.ts b/apps/desktop/src/renderer/lib/file-intake-client/index.ts new file mode 100644 index 0000000000..efd6cf8f50 --- /dev/null +++ b/apps/desktop/src/renderer/lib/file-intake-client/index.ts @@ -0,0 +1,140 @@ +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { useScratchTabsStore } from "renderer/screens/scratch/ScratchView"; +import { useTabsStore } from "renderer/stores/tabs"; + +/** Minimal router surface we need here. Avoids a hard coupling to the full + * TanStack router generic, which is awkward to type in an app context. */ +interface NavRouter { + navigate: (opts: { + to: string; + params?: Record; + }) => void | Promise; +} + +/** + * Wire the renderer side of the file-intake pipeline: + * + * - Subscribe to two tRPC channels the main process emits from + * `fileIntakeEmitter` (file-intake/index.ts) when an OS drop / argv / + * open-file event resolves to either a registered workspace target or a + * scratch target. AGENTS.md requires tRPC for IPC, so we route through the + * trpc-electron subscription machinery rather than raw ipcRenderer. + * - Intercept OS drag-and-drop onto the window so the user can drop files + * from Finder / Explorer directly into the app. + * + * Returns a cleanup function to tear down listeners / subscriptions (used + * for HMR). + */ +export function installFileIntakeClient(router: NavRouter): () => void { + const workspaceSub = + electronTrpcClient.scratch.onOpenWorkspaceBatch.subscribe(undefined, { + onData: (payload) => { + if (!payload.workspaceId) return; + const paths = payload.absolutePaths.filter( + (v): v is string => typeof v === "string" && v.length > 0, + ); + + // Always navigate — even for an empty paths batch, which is the + // "drag a folder that's a registered workspace" case (Q2:A with + // no specific file). Opening the workspace is the whole + // user-visible effect in that scenario. + void router.navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId: payload.workspaceId }, + }); + + if (paths.length === 0) return; + + const addFileViewerPane = useTabsStore.getState().addFileViewerPane; + for (const absolutePath of paths) { + addFileViewerPane(payload.workspaceId, { + filePath: absolutePath, + openInNewTab: true, + reuseExisting: "workspace", + }); + } + }, + onError: (err) => { + console.error("[file-intake] workspace batch subscription error:", err); + }, + }); + + const scratchSub = electronTrpcClient.scratch.onOpenScratchBatch.subscribe( + undefined, + { + onData: (payload) => { + const paths = payload.absolutePaths.filter( + (v): v is string => typeof v === "string" && v.length > 0, + ); + if (paths.length === 0) return; + useScratchTabsStore.getState().openPaths(paths); + void router.navigate({ to: "/scratch" }); + }, + onError: (err) => { + console.error("[file-intake] scratch batch subscription error:", err); + }, + }, + ); + + const extractDroppedPaths = (event: DragEvent): string[] => { + const webUtils = window.webUtils; + if (!webUtils?.getPathForFile) return []; + const files = event.dataTransfer?.files; + if (!files || files.length === 0) return []; + const paths: string[] = []; + for (const file of Array.from(files)) { + try { + const p = webUtils.getPathForFile(file); + if (p) paths.push(p); + } catch { + // Web-originated drops (e.g., dragging from a browser tab inside a + // BrowserPane) have no OS path. Ignore — v1 is OS drops only. + } + } + return paths; + }; + + const hasFilePayload = (event: DragEvent): boolean => { + const types = event.dataTransfer?.types; + if (!types) return false; + return Array.from(types).includes("Files"); + }; + + const onDragOver = (event: DragEvent) => { + // Bubble-phase fallback: existing drop zones (Chat attachments, Terminal + // paths, Sidebar project drops, TODO image drops, etc.) run in bubble + // phase and call preventDefault when they handle the drop. We only + // engage for OS file drags that nobody else claimed. + if (event.defaultPrevented) return; + if (!hasFilePayload(event)) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + }; + + const onDrop = (event: DragEvent) => { + if (event.defaultPrevented) return; + if (!hasFilePayload(event)) return; + event.preventDefault(); + const paths = extractDroppedPaths(event); + if (paths.length === 0) return; + electronTrpcClient.scratch.ingestDroppedPaths + .mutate({ absolutePaths: paths }) + .catch((err) => { + console.error("[file-intake] ingestDroppedPaths failed:", err); + }); + }; + + // Bubble phase + defaultPrevented guard: existing drop zones (prompt-input, + // Terminal, SidebarDropZone, StartView, ScriptsEditor, etc.) get first + // refusal. Only the uncaught drops — empty editor gutters, scratch view, + // workspace tab background — fall through to us. + document.addEventListener("dragover", onDragOver, false); + document.addEventListener("drop", onDrop, false); + + return () => { + workspaceSub.unsubscribe(); + scratchSub.unsubscribe(); + document.removeEventListener("dragover", onDragOver, false); + document.removeEventListener("drop", onDrop, false); + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index 3c7675116d..a31d9f9d4b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -51,6 +51,10 @@ function DashboardLayout() { const currentWorkspaceId = currentWorkspaceMatch !== false ? currentWorkspaceMatch.workspaceId : null; + // Q3:B — scratch route hides the workspace sidebar so a dropped file opens + // as a focused editor with no project chrome around it. + const isScratchRoute = matchRoute({ to: "/scratch", fuzzy: true }) !== false; + const { data: currentWorkspace } = electronTrpc.workspaces.get.useQuery( { id: currentWorkspaceId ?? "" }, { enabled: !!currentWorkspaceId }, @@ -114,7 +118,7 @@ function DashboardLayout() {
{!isTearoff && }
- {!isTearoff && isWorkspaceSidebarOpen && ( + {!isTearoff && !isScratchRoute && isWorkspaceSidebarOpen && ( ; +} diff --git a/apps/desktop/src/renderer/screens/scratch/ScratchView/ScratchView.tsx b/apps/desktop/src/renderer/screens/scratch/ScratchView/ScratchView.tsx new file mode 100644 index 0000000000..4d11e4724e --- /dev/null +++ b/apps/desktop/src/renderer/screens/scratch/ScratchView/ScratchView.tsx @@ -0,0 +1,44 @@ +import { useCallback } from "react"; +import { ScratchEditor } from "./components/ScratchEditor"; +import { ScratchEmpty } from "./components/ScratchEmpty"; +import { ScratchTabBar } from "./components/ScratchTabBar"; +import { useScratchTabsStore } from "./store"; + +export function ScratchView() { + const tabs = useScratchTabsStore((s) => s.tabs); + const activeTabId = useScratchTabsStore((s) => s.activeTabId); + const setActive = useScratchTabsStore((s) => s.setActive); + const closeTab = useScratchTabsStore((s) => s.closeTab); + + const activeTab = tabs.find((t) => t.id === activeTabId) ?? null; + + const handleClose = useCallback( + (id: string) => { + closeTab(id); + }, + [closeTab], + ); + + if (tabs.length === 0) { + return ; + } + + return ( +
+ +
+ {activeTab ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchEditor/ScratchEditor.tsx b/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchEditor/ScratchEditor.tsx new file mode 100644 index 0000000000..497637d386 --- /dev/null +++ b/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchEditor/ScratchEditor.tsx @@ -0,0 +1,131 @@ +import { useEffect, useRef, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import type { CodeEditorAdapter } from "renderer/screens/main/components/WorkspaceView/ContentView/components"; +import { CodeEditor } from "renderer/screens/main/components/WorkspaceView/components/CodeEditor"; +import { detectEditorLanguage } from "shared/language-registry"; + +interface ScratchEditorProps { + absolutePath: string; +} + +export function ScratchEditor({ absolutePath }: ScratchEditorProps) { + const editorRef = useRef(null); + const [draft, setDraft] = useState(null); + const [savedAt, setSavedAt] = useState(null); + const utils = electronTrpc.useUtils(); + + const { data, error, isLoading, refetch } = + electronTrpc.scratch.readFile.useQuery( + { absolutePath }, + { retry: false, refetchOnWindowFocus: false }, + ); + + const writeMut = electronTrpc.scratch.writeFile.useMutation({ + onSuccess: (res, variables) => { + setSavedAt(res.mtimeMs); + // Sync the readFile cache to what we just wrote so `hasChanges` + // (draft !== null && data.content !== draft) collapses to false and + // the status bar can advance from "unsaved" → "saved". + // + // Pull the content from `variables` (the exact string that reached + // disk) rather than the live `draft` state: if the user keeps + // typing while save is in flight, `draft` has already advanced past + // what we sent to writeFile, and seeding the cache with that newer + // value would make hasChanges flip false while disk still holds + // the older content — effectively losing the in-flight keystrokes + // from the dirty-tracking model. + utils.scratch.readFile.setData({ absolutePath }, (old) => { + if (!old || old.kind !== "text") return old; + return { + ...old, + content: variables.content, + size: res.size, + mtimeMs: res.mtimeMs, + }; + }); + }, + }); + + // Reset draft whenever the path or backing file content changes so the + // editor re-hydrates with the latest disk state. + useEffect(() => { + if (data?.kind === "text") { + setDraft(data.content); + } else { + setDraft(null); + } + }, [data]); + + if (isLoading) { + return ( +
+ 読み込み中… +
+ ); + } + + if (error) { + return ( +
+
+ ファイルを開けませんでした: {error.message} +
+ +
+ ); + } + + if (!data) return null; + + if (data.kind === "too-large") { + return ( +
+
ファイルサイズが大きすぎます
+
+ {(data.size / 1024 / 1024).toFixed(1)} MB / 上限{" "} + {(data.maxBytes / 1024 / 1024).toFixed(1)} MB +
+
+ ); + } + + const language = detectEditorLanguage(absolutePath); + const hasChanges = draft !== null && data.content !== draft; + + return ( +
+ setDraft(next)} + onSave={() => { + if (draft === null) return; + writeMut.mutate({ absolutePath, content: draft }); + }} + fillHeight + searchMode="overlay" + /> +
+ + {absolutePath} + + + {writeMut.isPending + ? "保存中…" + : hasChanges + ? "未保存の変更あり (⌘S で保存)" + : savedAt + ? "保存しました" + : "Scratch モード · Git / Agent / Chat / Terminal は無効"} + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchEditor/index.ts b/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchEditor/index.ts new file mode 100644 index 0000000000..a41c257692 --- /dev/null +++ b/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchEditor/index.ts @@ -0,0 +1 @@ +export { ScratchEditor } from "./ScratchEditor"; diff --git a/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchEmpty/ScratchEmpty.tsx b/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchEmpty/ScratchEmpty.tsx new file mode 100644 index 0000000000..7c85f80eee --- /dev/null +++ b/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchEmpty/ScratchEmpty.tsx @@ -0,0 +1,25 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; + +interface ScratchEmptyProps { + redirectToWorkspaceOnEmpty?: boolean; +} + +/** + * Q1:B — scratch tabs do not persist. Closing the last tab redirects the user + * back to the normal workspace entry point so the UI never sits in a blank + * scratch state between sessions. + */ +export function ScratchEmpty({ + redirectToWorkspaceOnEmpty = true, +}: ScratchEmptyProps) { + const navigate = useNavigate(); + useEffect(() => { + if (!redirectToWorkspaceOnEmpty) return; + navigate({ to: "/workspace", replace: true }); + }, [navigate, redirectToWorkspaceOnEmpty]); + + // Empty placeholder: effect above fires synchronously on mount to redirect, + // so we render nothing visible to avoid a one-frame flash. + return
; +} diff --git a/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchEmpty/index.ts b/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchEmpty/index.ts new file mode 100644 index 0000000000..740d13b065 --- /dev/null +++ b/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchEmpty/index.ts @@ -0,0 +1 @@ +export { ScratchEmpty } from "./ScratchEmpty"; diff --git a/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchTabBar/ScratchTabBar.tsx b/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchTabBar/ScratchTabBar.tsx new file mode 100644 index 0000000000..07521e854e --- /dev/null +++ b/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchTabBar/ScratchTabBar.tsx @@ -0,0 +1,74 @@ +import { cn } from "@superset/ui/utils"; +import { X } from "lucide-react"; +import { basename } from "../../utils/path"; + +export interface ScratchTab { + id: string; + absolutePath: string; +} + +interface ScratchTabBarProps { + tabs: ScratchTab[]; + activeTabId: string | null; + onSelect: (id: string) => void; + onClose: (id: string) => void; +} + +export function ScratchTabBar({ + tabs, + activeTabId, + onSelect, + onClose, +}: ScratchTabBarProps) { + if (tabs.length === 0) return null; + return ( +
+ {tabs.map((tab) => { + const isActive = tab.id === activeTabId; + return ( +
+ + +
+ ); + })} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchTabBar/index.ts b/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchTabBar/index.ts new file mode 100644 index 0000000000..5d6d1846a3 --- /dev/null +++ b/apps/desktop/src/renderer/screens/scratch/ScratchView/components/ScratchTabBar/index.ts @@ -0,0 +1 @@ +export { type ScratchTab, ScratchTabBar } from "./ScratchTabBar"; diff --git a/apps/desktop/src/renderer/screens/scratch/ScratchView/index.ts b/apps/desktop/src/renderer/screens/scratch/ScratchView/index.ts new file mode 100644 index 0000000000..07fe97be69 --- /dev/null +++ b/apps/desktop/src/renderer/screens/scratch/ScratchView/index.ts @@ -0,0 +1,2 @@ +export { ScratchView } from "./ScratchView"; +export { useScratchTabsStore } from "./store"; diff --git a/apps/desktop/src/renderer/screens/scratch/ScratchView/store.ts b/apps/desktop/src/renderer/screens/scratch/ScratchView/store.ts new file mode 100644 index 0000000000..a6362e5ffc --- /dev/null +++ b/apps/desktop/src/renderer/screens/scratch/ScratchView/store.ts @@ -0,0 +1,58 @@ +import { create } from "zustand"; +import type { ScratchTab } from "./components/ScratchTabBar"; + +interface ScratchTabsState { + tabs: ScratchTab[]; + activeTabId: string | null; + openPaths: (absolutePaths: string[]) => void; + setActive: (id: string) => void; + closeTab: (id: string) => void; + reset: () => void; +} + +function makeTabId(absolutePath: string): string { + // One tab per path, deduped. The path itself is the stable key. + return `scratch:${absolutePath}`; +} + +/** + * Q1:B — scratch tabs live only in renderer memory. There is no persistence + * across reloads or app restarts. Closing all tabs bounces the user back to + * the workspace picker via ScratchEmpty. + */ +export const useScratchTabsStore = create((set) => ({ + tabs: [], + activeTabId: null, + openPaths: (absolutePaths) => { + if (absolutePaths.length === 0) return; + set((state) => { + const existingIds = new Set(state.tabs.map((t) => t.id)); + const additions: ScratchTab[] = []; + for (const p of absolutePaths) { + const id = makeTabId(p); + if (!existingIds.has(id)) { + additions.push({ id, absolutePath: p }); + existingIds.add(id); + } + } + const tabs = [...state.tabs, ...additions]; + const lastPath = absolutePaths[absolutePaths.length - 1]; + const lastId = makeTabId(lastPath); + return { tabs, activeTabId: lastId }; + }); + }, + setActive: (id) => set({ activeTabId: id }), + closeTab: (id) => + set((state) => { + const idx = state.tabs.findIndex((t) => t.id === id); + if (idx < 0) return state; + const tabs = [...state.tabs.slice(0, idx), ...state.tabs.slice(idx + 1)]; + let activeTabId = state.activeTabId; + if (activeTabId === id) { + const nextIdx = Math.min(idx, tabs.length - 1); + activeTabId = tabs[nextIdx]?.id ?? null; + } + return { tabs, activeTabId }; + }), + reset: () => set({ tabs: [], activeTabId: null }), +})); diff --git a/apps/desktop/src/renderer/screens/scratch/ScratchView/utils/path.ts b/apps/desktop/src/renderer/screens/scratch/ScratchView/utils/path.ts new file mode 100644 index 0000000000..912decc4a6 --- /dev/null +++ b/apps/desktop/src/renderer/screens/scratch/ScratchView/utils/path.ts @@ -0,0 +1,5 @@ +export function basename(p: string): string { + if (!p) return ""; + const parts = p.split(/[\\/]/); + return parts[parts.length - 1] ?? p; +}