diff --git a/docs/local-artifacts-mockup.html b/docs/local-artifacts-mockup.html new file mode 100644 index 000000000..84456d4ad --- /dev/null +++ b/docs/local-artifacts-mockup.html @@ -0,0 +1,289 @@ + + + + + + 本机文件 · 专用 Header — 视觉伴生 v2 + + + +

本机文件 · 独立预览面板 Tab + 专用 Header — 视觉伴生 v2

+
本机文件不内联;点开后作为独立 预览面板 tab 打开,并使用专用 header + 操作菜单(区别于 artifact/upload 的下载型 header)。配色取自 main.css
+
+ + +
+
① Artifacts 下拉 — 新增 本机 / This computer 入口
+
+
+
Artifacts
+ 128 files +
+ +
+
本机 与 Remote 主机并列成组,显示机器名(os.hostname())。选中后打开文件浏览器(沿用远程 FileBrowserModal 骨架,独立弹窗),从中打开文件 → 生成独立预览 tab。
+
+ + +
+
② 独立预览面板 Tab — 本机文件专用 header(机器名 + 绝对路径 + 专用操作菜单)
+
+
+
results.csv
+
genome_summary.md
+
structure.pdb
+
+
+ +
+ MacBook Pro + /Users/roxi/Documents/datasets/genome_summary.md + + + + +
+
+
+
专用 header 区别于 artifact header: 左侧 机器名 chip + 可点击的绝对路径面包屑(artifact 只显示文件名),右侧操作从"下载"换成 Reveal in Finder + “…”菜单(见 ②B)。内容区 PreviewFileContent 完全复用。
+

Genome Assembly Summary

+

Assembly of SRR1234567 completed — N50 = 2.4 Mb across 148 contigs.

+

Key metrics

+
# assembly-stats
+total_length: 312,884,001
+gc_content:  0.412
+longest:     8,912,004
+

渲染管线(preview-registry)覆盖 Markdown / 代码 / CSV / xlsx / PDF / 图片 / molecule / pptx / docx。

+
+
+
+
+
+ header 差异点: + 🖥 机器名 chip + 绝对路径面包屑(可点击导航) + Reveal in Finder 快捷键 + “…” 操作菜单 + 全屏 · 关闭 tab +
+
+ + +
+
②B “…” 操作菜单 — 本机文件专属动作
+
+
+ MacBook Pro + …/datasets/genome_summary.md + +
+
+
Reveal in Finder
+
Copy path⌘C
+
Open with default app
+
+
Save as artifact
+
+
+
本机文件无"下载"语义(已在本地)。菜单动作:shell.showItemInFolder / 复制路径 / shell.openPath / 复制进项目 artifact 库。Save as artifact 仅当有活动项目时出现。
+
+ + +
+
③ 文件浏览器内 “Go to” — Home 固定 · Pin / Unpin
+
+
+ + Go to + /Users/roxi/Documents/datasets + +
+ +
+
Home 恒定置顶不可删。当前目录已 pin → 右侧显示 Unpin(✕)。持久化复用 computeBookmarks 同款机制(新键 localBookmarks)。
+
+ +
+ + + diff --git a/src/main/file-save.test.ts b/src/main/file-save.test.ts index ec6b3f75e..c71f4e188 100644 --- a/src/main/file-save.test.ts +++ b/src/main/file-save.test.ts @@ -71,6 +71,40 @@ describe('file save IPC handlers', () => { }) }) + it('accepts a local source and saves through the same managed pipeline', async () => { + const resolveManagedFilePath = vi.fn().mockResolvedValue('/Users/example/logs/proxy.log') + const copyTo = vi.fn().mockResolvedValue(undefined) + const close = vi.fn().mockResolvedValue(undefined) + const openManagedFile = vi.fn().mockResolvedValue({ copyTo, close }) + showSaveDialog.mockResolvedValue({ + canceled: false, + filePath: join(downloadsPath, 'proxy.log') + }) + registerFileSaveHandlers({ + resolveManagedFilePath, + openManagedFile + }) + + const result = await handlers.get('file:save-managed')!( + { sender: {} }, + { + source: 'local', + path: '/Users/example/logs/proxy.log', + suggestedName: 'proxy.log' + } + ) + + expect(resolveManagedFilePath).toHaveBeenCalledWith('local', { + path: '/Users/example/logs/proxy.log' + }) + expect(copyTo).toHaveBeenCalledWith(join(downloadsPath, 'proxy.log')) + expect(close).toHaveBeenCalledTimes(1) + expect(result).toEqual({ + saved: true, + filePath: join(downloadsPath, 'proxy.log') + }) + }) + it('copies the original pending file identity after it is finalized during Save As', async () => { const resolveManagedFilePath = vi.fn().mockResolvedValue('/managed/.pending/report.csv') const copyTo = vi.fn().mockResolvedValue(undefined) diff --git a/src/main/file-save.ts b/src/main/file-save.ts index 7dd2b656f..69de1fc9d 100644 --- a/src/main/file-save.ts +++ b/src/main/file-save.ts @@ -31,7 +31,8 @@ const assertSaveManagedFileRequest = (request: SaveManagedFileRequest): void => request === null || (request.source !== 'artifact' && request.source !== 'upload' && - request.source !== 'notebook-input') || + request.source !== 'notebook-input' && + request.source !== 'local') || typeof request.path !== 'string' || request.path.trim().length === 0 || typeof request.suggestedName !== 'string' diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 4b58391d7..1eeeee706 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -48,6 +48,7 @@ import { import { registerManagedPreviewIpcHandlers } from './managed-preview-ipc' import { registerManagedPreviewProtocol } from './managed-preview-protocol' import { ManagedPreviewResources } from './managed-preview-resources' +import type { ManagedPreviewSource } from '../shared/preview-resources' import { createOfficePreviewFrameProcessResolver, createOfficePreviewProcessMemoryReader @@ -90,6 +91,8 @@ import { SessionPersistenceCoordinator } from './session-persistence/coordinator import { type SessionPersistenceBackend } from './session-persistence/ipc' import { tryDecryptKey } from './settings/crypto' import { registerSettingsIpcHandlers } from './settings/ipc' +import { registerLocalFsIpcHandlers } from './local-fs/ipc' +import { LocalFsService } from './local-fs/service' import { getAppClaudeConfigDir } from './settings/provider-env' import { createDefaultSettingsService, type SettingsService } from './settings/service' import type { StoredConnectors } from './settings/types' @@ -252,28 +255,35 @@ const registerIpcHandlers = async ({ storageRoot: resolveDataRoot(), getClient: () => getProjectDbClient(resolveStorageRoot()) }) + // Shared local-fs service backs both the "This computer" browser IPC and the managed-preview + // resolver below, so path validation stays identical across both entry points. + const localFsService = new LocalFsService() // One source-neutral resolver keeps previews and user-requested exports on identical trust checks. const resolveManagedFilePath = ( - source: 'artifact' | 'upload' | 'notebook-input', + source: ManagedPreviewSource, request: { path: string; projectId?: string; sessionId?: string } - ): Promise => - source === 'artifact' - ? (() => { - const versionIdentity = parseArtifactVersionLocator(request.path) - return versionIdentity - ? artifactProvenanceRepository - .resolveVersionContent(versionIdentity) - .then((resolved) => resolved.path) - : artifactRepository.resolveManagedFilePath(request) - })() - : source === 'upload' - ? uploadRepository.resolveManagedUploadPath(request, { - projectId: request.projectId, - sessionId: request.sessionId - }) - : notebookInputRegistry - .resolvePreviewKey(request.path) - .then((target) => target.absolutePath) + ): Promise => { + if (source === 'artifact') { + const versionIdentity = parseArtifactVersionLocator(request.path) + return versionIdentity + ? artifactProvenanceRepository + .resolveVersionContent(versionIdentity) + .then((resolved) => resolved.path) + : artifactRepository.resolveManagedFilePath(request) + } + if (source === 'upload') { + return uploadRepository.resolveManagedUploadPath(request, { + projectId: request.projectId, + sessionId: request.sessionId + }) + } + if (source === 'notebook-input') { + return notebookInputRegistry + .resolvePreviewKey(request.path) + .then((target) => target.absolutePath) + } + return localFsService.resolveFilePath(request) + } // One registry owns short-lived capability URLs for both managed artifact repositories. const previewResources = new ManagedPreviewResources({ resolvePath: resolveManagedFilePath @@ -779,11 +789,19 @@ const registerIpcHandlers = async ({ ) registerUploadIpcHandlers(uploadRepository, { withSessionMutation: (projectId, sessionId, mutation) => - sessionPersistenceCoordinator.runSessionMutation(projectId, sessionId, mutation) + sessionPersistenceCoordinator.runSessionMutation(projectId, sessionId, mutation), + onStandaloneUploadSaved: (projectId, sessionId) => + broadcastToRenderers('project-files:changed', { + projectId, + sessionId, + sources: ['upload'], + kind: 'upsert' + }) }) ipcMain.handle('notebook:read-input-preview', (_event, request) => notebookInputRegistry.readPreview(request) ) + registerLocalFsIpcHandlers(localFsService) registerSessionPersistenceIpcHandlers(sessionPersistenceBackend, reviewRepository) registerProjectFilesIpcHandlers( projectFilesRepository, diff --git a/src/main/local-fs/ipc.ts b/src/main/local-fs/ipc.ts new file mode 100644 index 000000000..fb9b94a43 --- /dev/null +++ b/src/main/local-fs/ipc.ts @@ -0,0 +1,33 @@ +import { ipcMain } from 'electron' + +import type { ArtifactPreviewResult, ReadArtifactPreviewRequest } from '../../shared/artifacts' +import type { LocalDirListing, LocalRoots } from '../../shared/local-fs' +import { LocalFsService } from './service' + +// Channel names for the local ("This computer") file browser. Grouped under the local-fs: prefix. +export const LOCAL_FS_LIST_DIR_CHANNEL = 'local-fs:list-dir' +export const LOCAL_FS_READ_PREVIEW_CHANNEL = 'local-fs:read-preview' +export const LOCAL_FS_GET_ROOTS_CHANNEL = 'local-fs:get-roots' +export const LOCAL_FS_REVEAL_CHANNEL = 'local-fs:reveal' +export const LOCAL_FS_OPEN_PATH_CHANNEL = 'local-fs:open-path' + +// Registers the local-fs IPC handlers against a service instance (injectable for tests). +export const registerLocalFsIpcHandlers = ( + service: LocalFsService = new LocalFsService() +): void => { + ipcMain.handle(LOCAL_FS_LIST_DIR_CHANNEL, (_event, path: string): Promise => + service.listDir(path) + ) + ipcMain.handle( + LOCAL_FS_READ_PREVIEW_CHANNEL, + (_event, request: ReadArtifactPreviewRequest): Promise => + service.readPreview(request) + ) + ipcMain.handle(LOCAL_FS_GET_ROOTS_CHANNEL, (): LocalRoots => service.getRoots()) + ipcMain.handle(LOCAL_FS_REVEAL_CHANNEL, (_event, path: string): void => { + service.revealInFolder(path) + }) + ipcMain.handle(LOCAL_FS_OPEN_PATH_CHANNEL, (_event, path: string): Promise => + service.openPath(path) + ) +} diff --git a/src/main/local-fs/service.test.ts b/src/main/local-fs/service.test.ts new file mode 100644 index 000000000..4e4f88cd6 --- /dev/null +++ b/src/main/local-fs/service.test.ts @@ -0,0 +1,77 @@ +import { mkdir, mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +// electron's app/shell are unavailable in the test runner; stub the two calls the service makes. +vi.mock('electron', () => ({ + app: { getPath: (name: string) => (name === 'home' ? '/home/testuser' : '/tmp') }, + shell: { showItemInFolder: vi.fn(), openPath: vi.fn(async () => '') } +})) + +import { LocalFsService } from './service' + +let root = '' +const service = new LocalFsService() + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'local-fs-test-')) + await mkdir(join(root, 'sub')) + await mkdir(join(root, 'Alpha')) + await writeFile(join(root, 'notes.md'), '# Title\n\nHello world.\n', 'utf8') + await writeFile(join(root, 'data.csv'), 'a,b\n1,2\n', 'utf8') +}) + +afterAll(() => { + vi.restoreAllMocks() +}) + +describe('LocalFsService.listDir', () => { + it('lists entries directories-first, alphabetical', async () => { + const listing = await service.listDir(root) + expect(listing.entries.map((e) => e.name)).toEqual(['Alpha', 'sub', 'data.csv', 'notes.md']) + expect(listing.entries[0].isDirectory).toBe(true) + expect(listing.entries.find((e) => e.name === 'notes.md')?.size).toBeGreaterThan(0) + }) + + it('resolves symlinks and .. via realpath', async () => { + const link = join(root, 'link-to-sub') + await symlink(join(root, 'sub'), link) + const listing = await service.listDir(link) + // realpath both sides: macOS resolves the tmp dir through a /private symlink prefix. + expect(listing.resolvedPath).toBe(await realpath(join(root, 'sub'))) + }) + + it('rejects relative paths', async () => { + await expect(service.listDir('relative')).rejects.toThrow(/absolute/) + }) + + it('rejects paths with control characters', async () => { + await expect(service.listDir('/tmp/\x00x')).rejects.toThrow(/invalid characters/) + }) +}) + +describe('LocalFsService.readPreview', () => { + it('reads a bounded UTF-8 preview', async () => { + const result = await service.readPreview({ path: join(root, 'notes.md') }) + expect(result.content).toContain('# Title') + expect(result.encoding).toBe('utf8') + }) + + it('refuses to preview a directory', async () => { + await expect(service.readPreview({ path: join(root, 'sub') })).rejects.toThrow(/not a file/) + }) + + it('rejects relative preview paths', async () => { + await expect(service.readPreview({ path: 'notes.md' })).rejects.toThrow(/absolute/) + }) +}) + +describe('LocalFsService.getRoots', () => { + it('returns home and a machine name', () => { + const roots = service.getRoots() + expect(roots.home).toBe('/home/testuser') + expect(roots.machineName.length).toBeGreaterThan(0) + }) +}) diff --git a/src/main/local-fs/service.ts b/src/main/local-fs/service.ts new file mode 100644 index 000000000..924fcdf28 --- /dev/null +++ b/src/main/local-fs/service.ts @@ -0,0 +1,98 @@ +import { hostname, userInfo } from 'node:os' +import { readdir, realpath, stat } from 'node:fs/promises' +import { join } from 'node:path' + +import { app, shell } from 'electron' + +import type { ArtifactPreviewResult, ReadArtifactPreviewRequest } from '../../shared/artifacts' +import type { LocalDirEntry, LocalDirListing, LocalRoots } from '../../shared/local-fs' +import { LOCAL_DIR_ENTRY_CAP, sortLocalEntries, validateLocalPath } from '../../shared/local-fs' +import { readBoundedManagedFilePreview } from '../managed-file-preview' + +// Builds a user-facing machine name from the OS hostname (stripping a trailing ".local" that macOS +// appends) with a possessive owner prefix when the login name is available — e.g. "roxi's MacBook". +const buildMachineName = (): string => { + const raw = hostname().replace(/\.local$/i, '') + let owner = '' + try { + owner = userInfo().username + } catch { + // userInfo() throws when there is no OS user record (e.g. some CI); fall back to the bare host. + } + return owner ? `${owner}'s ${raw}` : raw +} + +// Throws a tagged error when the path fails the shared validation (non-absolute / control chars). +const assertValidLocalPath = (path: string): void => { + const problem = validateLocalPath(path) + if (problem === 'not_absolute') throw new Error('Local path must be absolute.') + if (problem === 'control_chars') throw new Error('Local path contains invalid characters.') +} + +// Service for browsing and previewing arbitrary local files. Unlike the artifact/upload readers, +// this deliberately does NOT confine paths to a storage root: the feature's contract is +// "Home start, full-disk navigable". Path validation rejects only malformed input; sensitive-file +// warnings are surfaced in the renderer (see isSensitiveLocalPath). +export class LocalFsService { + // Absolute paths for the browser's initial location and "Go to → Home". + getRoots(): LocalRoots { + return { home: app.getPath('home'), machineName: buildMachineName() } + } + + // Lists one directory. Resolves symlinks/.. via realpath, sorts dirs-first, caps entry count. + async listDir(path: string): Promise { + assertValidLocalPath(path) + const resolvedPath = await realpath(path) + const dirents = await readdir(resolvedPath, { withFileTypes: true }) + const truncated = dirents.length > LOCAL_DIR_ENTRY_CAP + const capped = truncated ? dirents.slice(0, LOCAL_DIR_ENTRY_CAP) : dirents + + const entries: LocalDirEntry[] = [] + for (const dirent of capped) { + const isDirectory = dirent.isDirectory() + // Stat each entry for size/mtime; skip entries that vanish or deny access mid-listing so one + // unreadable file never fails the whole directory. + try { + const entryStat = await stat(join(resolvedPath, dirent.name)) + entries.push({ + name: dirent.name, + isDirectory: isDirectory || entryStat.isDirectory(), + size: entryStat.isDirectory() ? 0 : entryStat.size, + mtimeMs: Math.round(entryStat.mtimeMs) + }) + } catch { + entries.push({ name: dirent.name, isDirectory, size: 0, mtimeMs: 0 }) + } + } + + return { entries: sortLocalEntries(entries), truncated, resolvedPath } + } + + // Validates + canonicalizes an absolute file path, asserting it is a regular file. Shared by the + // bounded preview reader and the streaming managed-preview resolver (binary renderers). + async resolveFilePath(request: { path: string }): Promise { + assertValidLocalPath(request.path) + const resolvedPath = await realpath(request.path) + const fileStat = await stat(resolvedPath) + if (!fileStat.isFile()) throw new Error('Local preview path is not a file.') + return resolvedPath + } + + // Reads a bounded preview of one local file, reusing the shared bounded reader. + async readPreview(request: ReadArtifactPreviewRequest): Promise { + const resolvedPath = await this.resolveFilePath(request) + return readBoundedManagedFilePreview(resolvedPath, request, 'Invalid local preview encoding.') + } + + // Reveals a file in the OS file manager (Finder / Explorer). + revealInFolder(path: string): void { + assertValidLocalPath(path) + shell.showItemInFolder(path) + } + + // Opens a file with the OS default application. Returns the shell error string, or '' on success. + async openPath(path: string): Promise { + assertValidLocalPath(path) + return shell.openPath(path) + } +} diff --git a/src/main/uploads/ipc.test.ts b/src/main/uploads/ipc.test.ts index 6810b7aaa..2044d5018 100644 --- a/src/main/uploads/ipc.test.ts +++ b/src/main/uploads/ipc.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -536,6 +536,118 @@ describe('default upload repository', () => { await claim(owner.event, { transferId: 'local-transfer-owned' }) }) + it('stages a local path upload with a main-side size and releases its lease immediately', async () => { + homeRoot = await mkdtemp(join(tmpdir(), 'open-science-upload-ipc-')) + const sourcePath = join(homeRoot, 'proxy.log') + await writeFile(sourcePath, 'local preview bytes') + const attachment = { + id: 'attachment-local-path', + sessionId: '.pending', + name: 'proxy.log', + originalName: 'proxy.log', + path: '/managed/.pending/proxy.log', + size: 19 + } + const repository = { + stageLocalFile: vi.fn(async () => attachment), + finalizePendingSessionUploads: vi.fn(async () => [attachment]), + abortTransfer: vi.fn(async () => undefined), + deleteUpload: vi.fn(async () => undefined) + } as unknown as UploadRepository + registerUploadIpcHandlers(repository) + const stageLocalPath = ipcHandlers.get('uploads:stage-local-path')! + const sender = createIpcEvent() + + await expect( + stageLocalPath(sender.event, { + transferId: 'local-path-transfer-1', + sourcePath, + name: 'proxy.log' + }) + ).resolves.toEqual(attachment) + + expect(repository.stageLocalFile).toHaveBeenCalledWith( + expect.objectContaining({ + transferId: 'local-path-transfer-1', + sourcePath, + name: 'proxy.log', + size: 19 + }), + expect.any(Function) + ) + // Finalization publishes the upload to SQLite so it appears in "Your uploads". + expect(repository.finalizePendingSessionUploads).toHaveBeenCalledWith( + 'standalone-uploads', + [attachment], + 'default-project' + ) + + // No claim arrives for this transfer: the lease is already released, so migration is not + // blocked and renderer teardown does not delete the staged upload. + beginMigration() + await waitForDataRootWriters() + sender.emit('destroyed') + await new Promise((resolve) => setImmediate(resolve)) + expect(repository.deleteUpload).not.toHaveBeenCalled() + }) + + it('calls onStandaloneUploadSaved after publishing a local path upload to SQLite', async () => { + homeRoot = await mkdtemp(join(tmpdir(), 'open-science-upload-ipc-')) + const sourcePath = join(homeRoot, 'notes.txt') + await writeFile(sourcePath, 'standalone upload content') + const attachment = { + id: 'attachment-standalone', + sessionId: '.pending', + name: 'notes.txt', + originalName: 'notes.txt', + path: '/managed/.pending/notes.txt', + size: 24 + } + const onStandaloneUploadSaved = vi.fn() + const repository = { + stageLocalFile: vi.fn(async () => attachment), + finalizePendingSessionUploads: vi.fn(async () => [attachment]), + abortTransfer: vi.fn(async () => undefined), + deleteUpload: vi.fn(async () => undefined) + } as unknown as UploadRepository + registerUploadIpcHandlers(repository, { onStandaloneUploadSaved }) + const stageLocalPath = ipcHandlers.get('uploads:stage-local-path')! + const sender = createIpcEvent() + + await stageLocalPath(sender.event, { + transferId: 'local-path-standalone', + sourcePath, + name: 'notes.txt' + }) + + // finalizePendingSessionUploads writes uploadFile + uploadVersion + ManagedFile rows so + // the file appears in "Your uploads" without an active conversation session. + expect(repository.finalizePendingSessionUploads).toHaveBeenCalledWith( + 'standalone-uploads', + [attachment], + 'default-project' + ) + // The callback lets ipc.ts broadcast project-files:changed to the renderer. + expect(onStandaloneUploadSaved).toHaveBeenCalledWith('default-project', 'standalone-uploads') + }) + + it('rejects a malformed local path upload request before staging', async () => { + const repository = { + stageLocalFile: vi.fn() + } as unknown as UploadRepository + registerUploadIpcHandlers(repository) + const stageLocalPath = ipcHandlers.get('uploads:stage-local-path')! + + await expect( + stageLocalPath(createIpcEvent().event, { + transferId: 'local-path-transfer-bad', + name: 'proxy.log', + sourcePath: ' ' + }) + ).rejects.toThrow('Invalid local path upload request.') + expect(repository.stageLocalFile).not.toHaveBeenCalled() + }) + it('does not expose the removed whole-file base64 staging channel', () => { registerUploadIpcHandlers({} as UploadRepository) diff --git a/src/main/uploads/ipc.ts b/src/main/uploads/ipc.ts index 0476c62b1..537c63822 100644 --- a/src/main/uploads/ipc.ts +++ b/src/main/uploads/ipc.ts @@ -1,4 +1,5 @@ import { ipcMain, type Event as ElectronEvent, type IpcMainInvokeEvent } from 'electron' +import { stat } from 'node:fs/promises' import type { ReadArtifactPreviewRequest } from '../../shared/artifacts' import type { @@ -6,11 +7,13 @@ import type { BeginUploadTransferRequest, DeleteUploadRequest, FinalizeUploadSessionRequest, + StageLocalPathUploadRequest, StageLocalUploadRequest, UploadTransferRequest, UploadTransferStatus, UploadedAttachment } from '../../shared/uploads' +import { DEFAULT_UPLOAD_PROJECT_NAME, STANDALONE_UPLOAD_SESSION_ID } from '../../shared/uploads' import { getProjectDbClient } from '../projects/prisma-client' import { resolveDataRoot, resolveStorageRoot } from '../storage-root' import { acquireDataRootWriter, withDataRootWrite } from '../storage/migration-state' @@ -31,6 +34,9 @@ const registerUploadIpcHandlers = ( sessionId: string, mutation: () => Promise ) => Promise + // Called after a standalone "Save as artifact" upload has been persisted to SQLite so + // the caller can broadcast a project-files:changed event to the renderer. + onStandaloneUploadSaved?: (projectId: string, sessionId: string) => void } = {} ): void => { // A chunk transfer spans several IPC calls but is one logical write. Holding the writer lease from @@ -160,8 +166,13 @@ const registerUploadIpcHandlers = ( return writer } - // Uploads write/mutate under the data root, so block them during the data-root copy→commit window. - ipcMain.handle('uploads:stage-local-file', async (event, request: StageLocalUploadRequest) => { + // Shared skeleton for native-path staging: one owner/writer lifecycle, with the renderer-facing + // handlers keeping only their differences (request validation, size source, claim vs. release). + const runLocalStaging = async ( + event: IpcMainInvokeEvent, + request: StageLocalUploadRequest, + options: { releaseOnCommit: boolean } + ): Promise => { const owner = registerUploadOwner(event) const existing = localWriters.get(request.transferId) ?? chunkWriters.get(request.transferId) if (existing) { @@ -188,13 +199,20 @@ const registerUploadIpcHandlers = ( await writer.cleanup throw new Error(`Upload renderer is no longer available: ${request.transferId}`) } + if (options.releaseOnCommit) releaseLocalWriter(request.transferId, writer) return attachment } catch (error) { if (writer.cancelled) await writer.cleanup else releaseLocalWriter(request.transferId, writer) throw error } - }) + } + + // Uploads write/mutate under the data root, so block them during the data-root copy→commit window. + ipcMain.handle('uploads:stage-local-file', async (event, request: StageLocalUploadRequest) => + // The composer claims the committed transfer later, so the writer lease stays held. + runLocalStaging(event, request, { releaseOnCommit: false }) + ) ipcMain.handle('uploads:claim-local-file', (event, request: UploadTransferRequest) => { const writer = getOwnedLocalWriter(event, request.transferId) // Chunk/Web transfers have no local ownership record, so claiming them is an idempotent no-op. @@ -207,6 +225,41 @@ const registerUploadIpcHandlers = ( } releaseLocalWriter(request.transferId, writer) }) + // Save-as-artifact from the local-file preview follows the composer upload pipeline, but the + // renderer supplies a path instead of a File and no composer will claim the transfer, so the + // writer lease is released as soon as the staged upload commits. + ipcMain.handle( + 'uploads:stage-local-path', + async (event, request: StageLocalPathUploadRequest) => { + if ( + typeof request !== 'object' || + request === null || + typeof request.transferId !== 'string' || + typeof request.name !== 'string' || + typeof request.sourcePath !== 'string' || + request.sourcePath.trim().length === 0 + ) { + throw new Error('Invalid local path upload request.') + } + // Stat before registering the writer so a stale renderer-side size can never reach staging. + const sourceInfo = await stat(request.sourcePath) + const attachment = await runLocalStaging( + event, + { ...request, size: sourceInfo.size }, + { releaseOnCommit: true } + ) + // Publish to SQLite (uploadFile + uploadVersion + ManagedFile) so the file shows up in + // "Your uploads" without an active conversation session. completeStagingUpload (called from + // publishAttachment inside finalizePendingSessionUploads) writes ManagedFile with + // source='upload', which is what listFiles({ collection: 'uploads' }) queries. + const projectId = request.projectId ?? DEFAULT_UPLOAD_PROJECT_NAME + await withDataRootWrite(() => + repository.finalizePendingSessionUploads(STANDALONE_UPLOAD_SESSION_ID, [attachment], projectId) + ) + options.onStandaloneUploadSaved?.(projectId, STANDALONE_UPLOAD_SESSION_ID) + return attachment + } + ) ipcMain.handle('uploads:begin-transfer', async (event, request: BeginUploadTransferRequest) => { const owner = registerUploadOwner(event) const localWriter = localWriters.get(request.transferId) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 7695ae4e4..97acf917b 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -49,6 +49,7 @@ import type { ProbeResult } from '../shared/compute' import type { DirListing, DownloadDest, LocalFile } from '../shared/remote-fs' +import type { LocalDirListing, LocalRoots } from '../shared/local-fs' import type { OpenLogFileResult, RevealLogFileResult } from '../shared/logs' import type { OpenSessionFromNotificationRequest } from '../shared/notifications' import type { @@ -200,6 +201,7 @@ import type { BeginUploadTransferRequest, DeleteUploadRequest, FinalizeUploadSessionRequest, + StageLocalPathUploadRequest, UploadTransferProgress, UploadTransferRequest, UploadTransferStatus, @@ -482,6 +484,8 @@ interface OpenScienceAPI { ): Promise // Acknowledges that the renderer committed a native-path upload into its draft state. claimLocalFile?(request: UploadTransferRequest): Promise + // Desktop-only save-as-artifact path for the local-file preview; staged like a composer upload. + stageLocalPath?(request: StageLocalPathUploadRequest): Promise beginTransfer(request: BeginUploadTransferRequest): Promise appendTransfer(request: AppendUploadTransferRequest): Promise getTransferStatus(request: UploadTransferRequest): Promise @@ -495,6 +499,18 @@ interface OpenScienceAPI { // Reads a bounded preview from upload storage using the same preview result shape as artifacts. readPreview(request: ReadArtifactPreviewRequest): Promise } + localFs: { + // Lists a directory on the machine Kiro runs on (the "This computer" browser). + listDir(path: string): Promise + // Reads a bounded preview of a local file (same result shape as artifacts/uploads). + readPreview(request: ReadArtifactPreviewRequest): Promise + // Home directory + friendly machine name for the browser's initial location and label. + getRoots(): Promise + // Reveals a local file in the OS file manager. + reveal(path: string): Promise + // Opens a local file with the OS default application; resolves to '' on success. + openPath(path: string): Promise + } notebook: { state(request: NotebookSessionRequest): Promise readInputPreview(request: ReadArtifactPreviewRequest): Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 5e7b24365..61ca585b2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -51,6 +51,7 @@ import type { ProbeResult } from '../shared/compute' import type { DirListing, DownloadDest, LocalFile } from '../shared/remote-fs' +import type { LocalDirListing, LocalRoots } from '../shared/local-fs' import type { OpenLogFileResult, RevealLogFileResult } from '../shared/logs' import type { OpenSessionFromNotificationRequest } from '../shared/notifications' import type { @@ -209,6 +210,7 @@ import type { BeginUploadTransferRequest, DeleteUploadRequest, FinalizeUploadSessionRequest, + StageLocalPathUploadRequest, UploadTransferProgress, UploadTransferRequest, UploadTransferStatus, @@ -535,6 +537,8 @@ type OpenScienceAPI = { ) => Promise // Acknowledges that the renderer committed a native-path upload into its draft state. claimLocalFile?: (request: UploadTransferRequest) => Promise + // Save-as-artifact from the local-file preview; staged like a composer upload. + stageLocalPath?: (request: StageLocalPathUploadRequest) => Promise beginTransfer: (request: BeginUploadTransferRequest) => Promise appendTransfer: (request: AppendUploadTransferRequest) => Promise getTransferStatus: (request: UploadTransferRequest) => Promise @@ -548,6 +552,18 @@ type OpenScienceAPI = { // Reads a bounded preview from upload storage using the same preview result shape as artifacts. readPreview: (request: ReadArtifactPreviewRequest) => Promise } + localFs: { + // Lists a directory on the machine Kiro runs on (the "This computer" browser). + listDir: (path: string) => Promise + // Reads a bounded preview of a local file (same result shape as artifacts/uploads). + readPreview: (request: ReadArtifactPreviewRequest) => Promise + // Home directory + friendly machine name for the browser's initial location and label. + getRoots: () => Promise + // Reveals a local file in the OS file manager. + reveal: (path: string) => Promise + // Opens a local file with the OS default application; resolves to '' on success. + openPath: (path: string) => Promise + } notebook: { state: (request: NotebookSessionRequest) => Promise readInputPreview: (request: ReadArtifactPreviewRequest) => Promise @@ -1120,6 +1136,9 @@ const api: OpenScienceAPI = { }, claimLocalFile: (request) => ipcRenderer.invoke('uploads:claim-local-file', request) as Promise, + // Save-as-artifact from the local-file preview; the renderer supplies the path directly. + stageLocalPath: (request) => + ipcRenderer.invoke('uploads:stage-local-path', request) as Promise, beginTransfer: (request) => ipcRenderer.invoke('uploads:begin-transfer', request) as Promise, appendTransfer: (request) => @@ -1140,6 +1159,15 @@ const api: OpenScienceAPI = { readPreview: (request) => ipcRenderer.invoke('uploads:read-preview', request) as Promise }, + localFs: { + // Local-fs IPC stays behind the preload bridge so renderer code never receives raw fs access. + listDir: (path) => ipcRenderer.invoke('local-fs:list-dir', path) as Promise, + readPreview: (request) => + ipcRenderer.invoke('local-fs:read-preview', request) as Promise, + getRoots: () => ipcRenderer.invoke('local-fs:get-roots') as Promise, + reveal: (path) => ipcRenderer.invoke('local-fs:reveal', path) as Promise, + openPath: (path) => ipcRenderer.invoke('local-fs:open-path', path) as Promise + }, notebook: { // Notebook commands stay behind typed IPC so renderer code never talks to local RPC directly. state: (request) => diff --git a/src/renderer/src/pages/settings/FileBrowserModal.tsx b/src/renderer/src/pages/settings/FileBrowserModal.tsx index 1e49ba495..a50176210 100644 --- a/src/renderer/src/pages/settings/FileBrowserModal.tsx +++ b/src/renderer/src/pages/settings/FileBrowserModal.tsx @@ -1,5 +1,5 @@ // Remote file browser modal (compute-file-preview, issue 02 + issue 03). -// Opened from the ComputePanel host card folder-icon button (and later from the Files panel REMOTE +// Opened from the ComputePanel host card folder-icon button (and later from the Files panel Remote // dropdown, issue 05). Presents a listbox-style directory listing with navigation, a detail panel for // selected files, and a Go-to dropdown with Scratch / Home / Pin / bookmarks. // diff --git a/src/renderer/src/pages/workspace/LocalFileBrowser.tsx b/src/renderer/src/pages/workspace/LocalFileBrowser.tsx new file mode 100644 index 000000000..03146b951 --- /dev/null +++ b/src/renderer/src/pages/workspace/LocalFileBrowser.tsx @@ -0,0 +1,517 @@ +// Local ("This computer") file browser. Rendered as one of the two containers the Files tab can show: +// the source dropdown swaps between the artifacts list and this browser, so it owns no tab or modal +// chrome of its own. Forked from the remote FileBrowserModal chrome (editable address bar, Go-to +// dropdown with a fixed Home + pin/unpin bookmarks) but: +// - transport is window.api.localFs (node:fs in main), not SSH +// - the toolbar has a single arrow, which goes to the parent directory; there is no history stack +// - opening a file does NOT show an inline detail panel; it opens a standalone preview-workbench +// tab (source:'local') that renders through the shared preview pipeline with a dedicated header +// - bookmarks persist under the reserved LOCAL_BOOKMARKS_KEY in the compute bookmark store +import { ArrowLeft, ChevronDown, Folder, File, Home, Pin, PinOff, RefreshCw } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { LocalDirEntry, LocalListingProblem, LocalRoots } from '../../../../shared/local-fs' +import { + describeLocalListingError, + isSensitiveLocalPath, + LOCAL_BOOKMARKS_KEY, + resolveLocalPath, + validateLocalPath +} from '../../../../shared/local-fs' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { usePreviewWorkbenchStore } from '@/stores/preview-workbench-store' + +import { createPreviewFileItemFromLocal, LOCAL_PREVIEW_SESSION_ID } from './preview-file-item' + +// True when two paths name the same directory, so equivalent spellings ("/a/b" vs "/a/b/") don't +// trigger a redundant listing call. Root stays "/". +const sameDirectory = (a: string, b: string): boolean => { + const strip = (path: string): string => (path.length > 1 ? path.replace(/\/+$/, '') : path) + return strip(a) === strip(b) +} + +// Formats a byte count as a short human-readable string. +const formatSize = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B` + const kb = bytes / 1024 + if (kb < 1024) return `${kb.toFixed(1)} KB` + const mb = kb / 1024 + return mb < 1024 ? `${mb.toFixed(1)} MB` : `${(mb / 1024).toFixed(1)} GB` +} + +// Human-readable relative time from a mtime; empty when unknown (mtimeMs 0). +const relativeTime = (mtimeMs: number): string => { + if (!mtimeMs) return '' + const sec = Math.round((Date.now() - mtimeMs) / 1000) + if (sec < 60) return `${sec}s` + if (sec < 3600) return `${Math.round(sec / 60)}m` + if (sec < 86400) return `${Math.round(sec / 3600)}h` + return `${Math.round(sec / 86400)}d` +} + +// Parent path (removes the last component); returns '/' at the root. +const parentPath = (p: string): string => { + if (p === '/') return '/' + const idx = p.replace(/\/$/, '').lastIndexOf('/') + return idx <= 0 ? '/' : p.slice(0, idx) +} + +// Shared look for the three icon-only toolbar buttons. They sit above a dense listing, so they read +// as secondary chrome: text-300 rather than the body color, darkening only on hover, and a hairline +// stroke that matches the weight of the 12px mono address text next to them. +const TOOLBAR_ICON_STROKE = 1.25 +const TOOLBAR_ICON_BUTTON = 'text-text-300 hover:text-text-100' + +type BrowserState = + | { kind: 'loading' } + | { kind: 'ok'; entries: LocalDirEntry[]; resolvedPath: string; truncated: boolean } + | { kind: 'error'; problem: LocalListingProblem } + +// Hover hint for the toolbar controls. Every one of them is icon-only (or icon + a two-word label), +// so the label alone doesn't say what the control does; the delay keeps hints from flashing while the +// pointer just crosses the toolbar. One shared TooltipProvider lives on the toolbar row. +const Hint = ({ + label, + children +}: { + label: string + children: React.ReactNode +}): React.JSX.Element => ( + + {children} + {label} + +) + +// Body of one Go-to row: the label plus the absolute path it resolves to. Without the path, "Home" +// says nothing about where it lands and two folders pinned from different trees look identical when +// they share a basename (…/2024/data vs …/2025/data). +const GoToRow = ({ + icon, + label, + path +}: { + icon: React.ReactNode + label: string + path: string +}): React.JSX.Element => ( + <> + {icon} + + {label} + {/* title carries the untruncated path: CSS clips the tail, which is the part that identifies + a deeply nested folder. */} + + {path} + + + +) + +// Go-to dropdown: Home is fixed at the top (never removable); pinned folders follow with Unpin. +// Built on the shared shadcn DropdownMenu, so click-outside, Escape, focus trapping and the +// trigger's aria-expanded all come from Radix instead of hand-rolled open state. +const GoToMenu = ({ + home, + bookmarks, + currentPath, + isBookmarked, + onNavigate, + onPinCurrent, + onRemoveBookmark +}: { + home: string | undefined + bookmarks: string[] + currentPath: string + isBookmarked: boolean + onNavigate: (path: string) => void + onPinCurrent: () => void + onRemoveBookmark: (path: string) => void +}): React.JSX.Element => ( + + + {/* Label at the default 13px: at text-xs it was the smallest type in the row despite being + the only worded control there. */} + + + + + + {home ? ( + onNavigate(home)}> + } + label="Home" + path={home} + /> + + ) : null} + {!isBookmarked && currentPath ? ( + + } + label="Pin current folder" + path={currentPath} + /> + + ) : null} + {bookmarks.length > 0 ? ( + <> + + Pinned + + {bookmarks.map((path) => ( + onNavigate(path)} + > + } + label={path.split('/').pop() || path} + path={path} + /> + {/* The crossed-out pushpin can read as "delete this folder", so the hint says it + unpins. Its hover fill is surface-control-hover, a step darker than the muted fill + the highlighted row already shows, so the square reads as its own control. + stopPropagation keeps the click from selecting the row (which would navigate and + close the menu) — unpinning leaves the list open so several can go at once. */} + + + + + ))} + + ) : null} + + +) + +// Directory listing table: dirs first, single-click opens (navigate for dirs, preview tab for files). +const LocalListing = ({ + state, + onOpenEntry +}: { + state: BrowserState + onOpenEntry: (entry: LocalDirEntry) => void +}): React.JSX.Element => { + if (state.kind === 'loading') { + return ( +
+ Loading… +
+ ) + } + if (state.kind === 'error') { + // A missing or unreadable path is an ordinary typo, not a fault: state it quietly in the body + // text color and echo the path in mono underneath, rather than shouting the raw errno in red. + return ( +
+

{state.problem.summary}

+ {state.problem.path ? ( +

+ {state.problem.path} +

+ ) : null} +
+ ) + } + return ( +
+ {state.truncated ? ( +
+ Showing the first entries only — this directory is very large. +
+ ) : null} + {state.entries.length === 0 ? ( +
+ Empty folder +
+ ) : ( +
    + {state.entries.map((entry) => ( +
  • + +
  • + ))} +
+ )} +
+ ) +} + +export const LocalFileBrowser = ({ + onEntryCountChange +}: { + // Reports the visible entry count so the Files tab header can show it next to the source picker. + onEntryCountChange?: (count: number | undefined) => void +}): React.JSX.Element => { + const [roots, setRoots] = useState(null) + const [cwd, setCwd] = useState('') + const [state, setState] = useState({ kind: 'loading' }) + const [addressInput, setAddressInput] = useState('') + const [bookmarks, setBookmarks] = useState([]) + const upsertAndActivateItem = usePreviewWorkbenchStore((s) => s.upsertAndActivateItem) + // Latest count reporter, read through a ref so navigate() stays identity-stable even when the + // parent passes a fresh closure. + const onEntryCountChangeRef = useRef(onEntryCountChange) + useEffect(() => { + onEntryCountChangeRef.current = onEntryCountChange + }, [onEntryCountChange]) + + // Clear the reported count when this container goes away, so the header stops showing a stale one. + useEffect( + () => () => { + onEntryCountChangeRef.current?.(undefined) + }, + [] + ) + + // Loads one directory into the listing. Navigation is a single axis (parent arrow, address bar, + // Go-to, entry double-click), so there is no history stack to maintain. + const navigate = useCallback(async (target: string): Promise => { + setState({ kind: 'loading' }) + try { + const listing = await window.api.localFs.listDir(target) + setState({ + kind: 'ok', + entries: listing.entries, + resolvedPath: listing.resolvedPath, + truncated: listing.truncated + }) + setCwd(listing.resolvedPath) + setAddressInput(listing.resolvedPath) + onEntryCountChangeRef.current?.(listing.entries.length) + } catch (err) { + setState({ + kind: 'error', + problem: describeLocalListingError((err as Error).message ?? '', target) + }) + onEntryCountChangeRef.current?.(undefined) + } + }, []) + + // On mount: fetch roots + bookmarks, then land in Home. + useEffect(() => { + void (async () => { + const [fetchedRoots, fetchedBookmarks] = await Promise.all([ + window.api.localFs.getRoots(), + window.api.compute.bookmarksGet(LOCAL_BOOKMARKS_KEY) + ]) + setRoots(fetchedRoots) + setBookmarks(fetchedBookmarks) + await navigate(fetchedRoots.home) + })() + }, [navigate]) + + const listing = state.kind === 'ok' ? state : null + const currentPath = listing?.resolvedPath ?? cwd + const isAtRoot = currentPath === '/' + + // Submit/blur only re-reads the directory when the typed path actually resolves somewhere new, so + // tabbing out of an untouched address bar costs no listing call. A no-op edit (trailing slash, + // relative form of the same dir) snaps the field back to the canonical path instead. + const handleAddressSubmit = (e: React.FormEvent): void => { + e.preventDefault() + const resolved = resolveLocalPath(currentPath, addressInput.trim()) + const invalid = validateLocalPath(resolved) + if (invalid) { + setState({ + kind: 'error', + problem: { + summary: + invalid === 'not_absolute' + ? 'Enter an absolute path, starting at /.' + : 'That path contains invalid characters.' + } + }) + return + } + if (sameDirectory(resolved, currentPath)) { + setAddressInput(currentPath) + return + } + void navigate(resolved) + } + + // Directory → navigate in; file → open a preview-workbench tab. Sensitive paths (credential dirs + // like .ssh, private keys, dotenv files) warn first, whether entering or opening. + const handleOpenEntry = (entry: LocalDirEntry): void => { + const path = `${currentPath.replace(/\/$/, '')}/${entry.name}` + if (isSensitiveLocalPath(path)) { + const prompt = entry.isDirectory + ? `"${entry.name}" may contain credentials or secrets. Open this folder anyway?` + : `"${entry.name}" may contain credentials or secrets. Open it anyway?` + if (!window.confirm(prompt)) return + } + if (entry.isDirectory) { + void navigate(path) + return + } + upsertAndActivateItem( + createPreviewFileItemFromLocal({ + sessionId: LOCAL_PREVIEW_SESSION_ID, + path, + name: entry.name, + size: entry.size, + mtimeMs: entry.mtimeMs + }) + ) + } + + const isBookmarked = bookmarks.includes(currentPath) + + const handleToggleBookmark = async (): Promise => { + const next = isBookmarked + ? bookmarks.filter((b) => b !== currentPath) + : [...bookmarks, currentPath] + setBookmarks(next) + await window.api.compute.bookmarksSet(LOCAL_BOOKMARKS_KEY, next) + } + + const handleRemoveBookmark = async (path: string): Promise => { + const next = bookmarks.filter((b) => b !== path) + setBookmarks(next) + await window.api.compute.bookmarksSet(LOCAL_BOOKMARKS_KEY, next) + } + + return ( +
+ {/* Toolbar */} + +
+ {/* One arrow only: it goes to the parent directory. A separate up-arrow beside it read as a + duplicate of the same action, so this is the single way to move a level out. */} + + + + + {/* Go-to dropdown: fixed Home + pinned bookmarks. Selecting an item closes the menu + itself, so nothing here tracks open state. */} + void navigate(path)} + onPinCurrent={() => void handleToggleBookmark()} + onRemoveBookmark={(path) => void handleRemoveBookmark(path)} + /> + + {/* Always-editable address bar */} +
+ setAddressInput(e.target.value)} + onBlur={handleAddressSubmit} + spellCheck={false} + className="w-full rounded-md border border-border bg-bg-000 px-2 py-1 font-mono text-xs text-text-100 outline-none focus:text-text-000 focus:ring-2 focus:ring-ring/50" + aria-label="Directory path" + /> +
+ + + + + + + +
+
+ + {/* Listing */} + +
+ ) +} diff --git a/src/renderer/src/pages/workspace/LocalFileHeaderActions.tsx b/src/renderer/src/pages/workspace/LocalFileHeaderActions.tsx new file mode 100644 index 000000000..efb4d0c12 --- /dev/null +++ b/src/renderer/src/pages/workspace/LocalFileHeaderActions.tsx @@ -0,0 +1,192 @@ +// Action cluster shown in the preview header for local ("This computer") files, replacing the +// artifact/upload "Download" button row. The primary action reloads the preview from disk; the +// "…" menu shows the file identity, Copy path, and an "On this machine" group with Download +// (same save pipeline as managed files) and Save as artifact (same staging pipeline as composer +// uploads). Kept in its own module so PreviewFileSurface stays source-neutral. +import { + Check, + ClipboardCopy, + Download, + ExternalLink, + File, + MoreHorizontal, + PackagePlus, + RotateCw +} from 'lucide-react' +import { useEffect, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { useNavigationStore } from '@/stores/navigation-store' + +// Primary labeled action for the "Preview unavailable" fallback of a local file: opening it in its +// default OS app is the local analogue of the artifact/upload "Download" affordance. +export const LocalFileFallbackAction = ({ + path, + className +}: { + path: string + className?: string +}): React.JSX.Element => ( + +) + +type SaveAsArtifactState = 'idle' | 'saving' | 'saved' + +export const LocalFileHeaderActions = ({ + path, + name, + onReload, + tooltipClassName +}: { + path: string + name: string + onReload?: () => void + tooltipClassName?: string +}): React.JSX.Element => { + const [copied, setCopied] = useState(false) + // In-memory only by design: the staged upload joins the normal upload lifecycle, and the header + // just reflects that this preview already handed the file over. + const [saveAsArtifactState, setSaveAsArtifactState] = useState('idle') + const copiedTimer = useRef>(undefined) + const activeProjectId = useNavigationStore((state) => state.activeProjectId) + + // Clear any pending "Copied" reset when the header unmounts (tab closed within the 1.5s window). + useEffect(() => () => clearTimeout(copiedTimer.current), []) + + const copyPath = async (): Promise => { + await navigator.clipboard.writeText(path) + setCopied(true) + clearTimeout(copiedTimer.current) + copiedTimer.current = setTimeout(() => setCopied(false), 1500) + } + const download = async (): Promise => { + try { + await window.api.saveManagedFile({ source: 'local', path, suggestedName: name }) + } catch (error) { + console.error(`Failed to download local file: ${name}`, error) + } + } + const stageLocalPath = window.api.uploads.stageLocalPath + + const saveAsArtifact = async (): Promise => { + if (!stageLocalPath || saveAsArtifactState === 'saving') return + + setSaveAsArtifactState('saving') + try { + await stageLocalPath({ + transferId: crypto.randomUUID(), + name, + sourcePath: path, + projectId: activeProjectId + }) + setSaveAsArtifactState('saved') + } catch (error) { + console.error(`Failed to save local file as artifact: ${name}`, error) + setSaveAsArtifactState('idle') + } + } + + const canSaveAsArtifact = saveAsArtifactState !== 'saved' && typeof stageLocalPath === 'function' + + return ( + <> + {saveAsArtifactState === 'saved' ? ( + + + ) : null} + {onReload ? ( + + + + + + Reload from disk + + + ) : null} + + + + + {/* z-[70] keeps the menu above the full-screen preview modal (z-[61]). */} + +
+
+ + void copyPath()} className="gap-2"> + {/* The label already flips to "Copied", so the checkmark needs no color of its own. */} + {copied ? ( + + + On this machine + + void download()} className="gap-2"> + + {canSaveAsArtifact ? ( + void saveAsArtifact()} + disabled={saveAsArtifactState === 'saving'} + className="gap-2" + > + + ) : null} +
+
+ + ) +} diff --git a/src/renderer/src/pages/workspace/PreviewFileSurface.test.tsx b/src/renderer/src/pages/workspace/PreviewFileSurface.test.tsx index 673ffdded..92fb8e7be 100644 --- a/src/renderer/src/pages/workspace/PreviewFileSurface.test.tsx +++ b/src/renderer/src/pages/workspace/PreviewFileSurface.test.tsx @@ -405,3 +405,153 @@ describe('PreviewFileSurface Provenance entry', () => { expect(zIndexFromClassName(menu!)).toBeGreaterThan(zIndexFromClassName(dialog!)) }) }) + +const localItem: PreviewFileItem = { + id: 'local:/Users/example/logs/proxy.log', + sessionId: '__local_files__', + type: 'file', + title: 'proxy.log', + name: 'proxy.log', + path: '/Users/example/logs/proxy.log', + format: 'text', + source: 'local' +} + +const setupLocalApi = (): void => { + window.api.localFs = { + reveal: vi.fn(), + openPath: vi.fn() + } as unknown as typeof window.api.localFs + window.api.saveManagedFile = vi.fn().mockResolvedValue({ saved: true }) + window.api.uploads = { + stageLocalPath: vi + .fn() + .mockResolvedValue({ id: 'attachment-1', path: '/managed/.pending/proxy.log' }) + } as unknown as typeof window.api.uploads +} + +const clickMenuItem = async (label: string): Promise => { + const menuItem = [...document.body.querySelectorAll('[role="menuitem"]')].find( + (element) => element.textContent?.includes(label) + ) + if (!menuItem) throw new Error(`menu item not found: ${label}`) + await click(menuItem) +} + +describe('PreviewFileSurface local file header', () => { + beforeEach(() => { + setupLocalApi() + }) + + it('shows a This computer pill before the file path in a light style', async () => { + await act(async () => { + root.render() + }) + + const pathLine = container.querySelector('[data-testid="local-file-path"]') + expect(pathLine?.textContent).toBe('This computer/Users/example/logs/proxy.log') + expect(pathLine?.className).toContain('text-text-100') + expect(pathLine?.querySelector('span')?.className).toContain('rounded-full') + }) + + it('offers a reload button instead of a standalone reveal button', async () => { + await act(async () => { + root.render() + }) + + expect(container.querySelector('[aria-label="Reveal in Finder"]')).toBeNull() + previewContentSpy.mockClear() + + await click(container.querySelector('[aria-label="Reload file"]')) + + // Reload remounts the content tree so the preview is re-read from disk. + expect(previewContentSpy).toHaveBeenCalled() + }) + + it('groups the menu per the local-file design: identity header, Copy path, On this machine', async () => { + await act(async () => { + root.render() + }) + await openMenu(container.querySelector('[aria-label="More actions"]')) + + const menu = document.body.querySelector('[role="menu"]') + // The menu opens with the file identity: name above the full path in a light tone. + expect(menu?.textContent).toContain('proxy.log') + expect(menu?.textContent).toContain('/Users/example/logs/proxy.log') + expect(menu?.textContent).toContain('Copy path') + expect(menu?.textContent).toContain('On this machine') + expect(menu?.textContent).toContain('Download') + expect(menu?.textContent).toContain('Save as artifact') + expect(menu?.textContent).not.toContain('Reveal in Finder') + expect(menu?.textContent).not.toContain('Open with default app') + expect(menu?.textContent).not.toContain('Annotate') + expect(menu?.textContent).not.toContain('Delete') + + await clickMenuItem('Download') + + expect(window.api.saveManagedFile).toHaveBeenCalledWith({ + source: 'local', + path: '/Users/example/logs/proxy.log', + suggestedName: 'proxy.log' + }) + }) + + it('opens its menu above an expanded preview modal', async () => { + await act(async () => { + root.render( +
+ +
+ ) + }) + + await openMenu(container.querySelector('[aria-label="More actions"]')) + + const dialog = container.querySelector('[role="dialog"]') + const menu = document.body.querySelector('[role="menu"]') + expect(menu).not.toBeNull() + expect(menu?.textContent).toContain('Save as artifact') + expect(zIndexFromClassName(menu!)).toBeGreaterThan(zIndexFromClassName(dialog!)) + }) + + it('shows no tooltip for the More actions trigger', async () => { + await act(async () => { + root.render() + }) + const trigger = container.querySelector('[aria-label="More actions"]')! + + await act(async () => { + trigger.dispatchEvent(new MouseEvent('pointermove', { bubbles: true })) + await new Promise((resolve) => setTimeout(resolve, 300)) + }) + + // Tooltip content carries bg-text-000; the dropdown menu content (bg-popover) must not match. + expect( + document.body.querySelector('[data-radix-popper-content-wrapper] .bg-text-000') + ).toBeNull() + }) + + it('saves as artifact, then swaps the menu item for a saved chip', async () => { + await act(async () => { + root.render() + }) + await openMenu(container.querySelector('[aria-label="More actions"]')) + await clickMenuItem('Save as artifact') + + expect(window.api.uploads.stageLocalPath).toHaveBeenCalledWith({ + transferId: expect.any(String), + name: 'proxy.log', + sourcePath: '/Users/example/logs/proxy.log' + }) + const chip = container.querySelector('[data-testid="saved-as-artifact"]') + expect(chip).not.toBeNull() + // The Saved chip leads the local action cluster, ahead of the reload button. + const reload = container.querySelector('[aria-label="Reload file"]') + expect(chip!.compareDocumentPosition(reload!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + + await openMenu(container.querySelector('[aria-label="More actions"]')) + expect(document.body.querySelector('[role="menu"]')?.textContent).not.toContain( + 'Save as artifact' + ) + }) +}) diff --git a/src/renderer/src/pages/workspace/PreviewFileSurface.tsx b/src/renderer/src/pages/workspace/PreviewFileSurface.tsx index 777caad99..a4a84a013 100644 --- a/src/renderer/src/pages/workspace/PreviewFileSurface.tsx +++ b/src/renderer/src/pages/workspace/PreviewFileSurface.tsx @@ -15,6 +15,7 @@ import { } from '@/components/ui/dropdown-menu' import { ExtensionPreservingFileName } from './ExtensionPreservingFileName' +import { LocalFileHeaderActions } from './LocalFileHeaderActions' import { ManagedFileDownloadButton } from './ManagedFileDownloadButton' import { createPreviewFileItemForArtifactVersion, @@ -31,6 +32,7 @@ type PreviewFileSurfaceProps = { onClose: () => void onOpenFullScreen?: () => void onOpenProvenance?: () => void + onReload?: () => void provenanceEntry?: 'menu' | 'leading' | 'trailing' } @@ -69,6 +71,7 @@ const PreviewFileHeader = ({ onClose, onOpenFullScreen, onOpenProvenance, + onReload, provenanceEntry = 'menu', tooltipClassName }: Pick< @@ -77,12 +80,16 @@ const PreviewFileHeader = ({ | 'onClose' | 'onOpenFullScreen' | 'onOpenProvenance' + | 'onReload' | 'provenanceEntry' | 'tooltipClassName' >): React.JSX.Element => (
{onOpenProvenance && provenanceEntry === 'leading' ? ( + {item.source === 'local' ? ( + + This computer + {item.path} + + ) : null} - {item.title} + + {item.source === 'local' ? item.path : item.title} + - {onOpenProvenance && provenanceEntry === 'trailing' ? ( - - ) : null} - - {item.originSession?.state === 'deleted' ? ( - - Source session deleted - - ) : null} - {onOpenProvenance && provenanceEntry === 'menu' ? ( - - - - - - - - - - ) : null} + Source session deleted + + ) : null} + {onOpenProvenance && provenanceEntry === 'menu' ? ( + + + + + + + + + + ) : null} + + )} {onOpenFullScreen ? ( @@ -246,6 +277,8 @@ const PreviewFileSurface = ({ provenanceEntry = 'menu' }: PreviewFileSurfaceProps): React.JSX.Element => { const [provenanceTarget, setProvenanceTarget] = useState() + // Bumping this token remounts the content tree so a local file is re-read from disk. + const [reloadToken, setReloadToken] = useState(0) const [versionOverride, setVersionOverride] = useState<{ key: string item: PreviewFileItem @@ -352,6 +385,7 @@ const PreviewFileSurface = ({ item={resolvedPreviewItem} onClose={onClose} onOpenFullScreen={onOpenFullScreen} + onReload={() => setReloadToken((token) => token + 1)} provenanceEntry={provenanceEntry} onOpenProvenance={ previewItem.source !== 'upload' && previewItem.artifactId && projectId @@ -377,7 +411,7 @@ const PreviewFileSurface = ({ /> ) : renderContent ? ( ) : null} diff --git a/src/renderer/src/pages/workspace/PreviewPanel.test.tsx b/src/renderer/src/pages/workspace/PreviewPanel.test.tsx index 3cda7f50d..47fceb95f 100644 --- a/src/renderer/src/pages/workspace/PreviewPanel.test.tsx +++ b/src/renderer/src/pages/workspace/PreviewPanel.test.tsx @@ -261,6 +261,18 @@ describe('PreviewPanel', () => { expect(dialog?.querySelector('[data-testid="file-content"]')).toBe(compactContent) expect(dialog?.querySelector(`[aria-label="Open full screen preview of ${name}"]`)).toBeNull() + await act(async () => { + dialog + ?.querySelector(`[aria-label="Close preview of ${name}"]`) + ?.dispatchEvent(new MouseEvent('pointermove', { bubbles: true })) + await new Promise((resolve) => setTimeout(resolve, 300)) + }) + // The full-screen dialog floats at z-[61]; header tooltips must layer above it. Radix puts + // role="tooltip" on a visually-hidden span, so assert on the styled content div instead. + expect( + document.body.querySelector('[data-radix-popper-content-wrapper] [data-state]')?.className + ).toContain('z-[70]') + await act(async () => { dialog ?.querySelector(`[aria-label="Close preview of ${name}"]`) diff --git a/src/renderer/src/pages/workspace/PreviewPanel.tsx b/src/renderer/src/pages/workspace/PreviewPanel.tsx index 0c35fe94f..cff1c561f 100644 --- a/src/renderer/src/pages/workspace/PreviewPanel.tsx +++ b/src/renderer/src/pages/workspace/PreviewPanel.tsx @@ -347,6 +347,8 @@ const PreviewFilePanel = ({ onClose(item.id)} onOpenFullScreen={isFullScreenOpen ? undefined : openFullScreen} provenanceEntry={isFullScreenOpen ? 'trailing' : 'menu'} @@ -422,7 +424,12 @@ const PreviewPanel = ({ id={getPreviewPanelId(item.id)} aria-labelledby={getPreviewTabId(item.id)} hidden - /> + > + {/* Keep tool panels mounted so component state (e.g. the local file + browser's current directory) survives switching to a file preview and back. + File panels re-create on activation anyway (key encodes path+mtime). */} + {item.type === 'tool' ? : null} + ) } diff --git a/src/renderer/src/pages/workspace/ProjectFilesView.test.tsx b/src/renderer/src/pages/workspace/ProjectFilesView.test.tsx index e8dc6f257..b292260fd 100644 --- a/src/renderer/src/pages/workspace/ProjectFilesView.test.tsx +++ b/src/renderer/src/pages/workspace/ProjectFilesView.test.tsx @@ -1320,7 +1320,7 @@ describe('ProjectFilesView', () => { expect(container.querySelectorAll('[data-testid="project-files-end"]')).toHaveLength(1) }) - it('opens a filter menu without This computer entries', async () => { + it('opens a filter menu with a "this computer" entry', async () => { await renderView([ createSession({ title: 'Session A', @@ -1355,7 +1355,8 @@ describe('ProjectFilesView', () => { expect(document.body.textContent).toContain('All artifacts') expect(document.body.textContent).toContain('Your uploads') expect(document.body.textContent).toContain('Session A') - expect(document.body.textContent).not.toContain('This computer') + // localFs is absent in this environment, so the entry falls back to its default label. + expect(document.body.textContent).toContain('This computer') expect(document.body.querySelector('[data-filter-id="all"] .lucide-boxes')).not.toBeNull() }) @@ -3233,7 +3234,7 @@ const createHost = (overrides: Partial = {}): ComputeHost => ({ ...overrides }) -describe('ProjectFilesView — REMOTE section in source dropdown', () => { +describe('ProjectFilesView — Remote section in source dropdown', () => { let container: HTMLDivElement let root: Root @@ -3286,6 +3287,17 @@ describe('ProjectFilesView — REMOTE section in source dropdown', () => { bookmarksGet: vi.fn().mockResolvedValue([]), bookmarksSet: vi.fn().mockResolvedValue(undefined) }, + localFs: { + getRoots: vi.fn().mockResolvedValue({ home: '/Users/roxi', machineName: 'TychoStation' }), + listDir: vi.fn().mockResolvedValue({ + entries: [ + { name: 'Projects', isDirectory: true, size: 0, mtimeMs: 1710000000000 }, + { name: 'notes.md', isDirectory: false, size: 2048, mtimeMs: 1710000001000 } + ], + resolvedPath: '/Users/roxi', + truncated: false + }) + }, projectFiles: { getOverview: vi.fn().mockResolvedValue({ totalCount: 0, @@ -3325,7 +3337,7 @@ describe('ProjectFilesView — REMOTE section in source dropdown', () => { }) } - it('shows REMOTE section label in source dropdown when hosts are present', async () => { + it('shows the Remote section label in source dropdown when hosts are present', async () => { useComputeStore.setState({ ...createInitialComputeState(), isLoaded: true, @@ -3346,11 +3358,154 @@ describe('ProjectFilesView — REMOTE section in source dropdown', () => { filterButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) - expect(document.body.textContent).toContain('REMOTE') + expect(document.body.textContent).toContain('Remote') expect(document.body.textContent).toContain('biowulf') }) - it('disables unreachable hosts in the REMOTE section', async () => { + it('swaps the artifacts list for the local browser when the device is picked', async () => { + await renderFilesView() + + // Starts on the artifacts container, labelled with the resolved device name in the dropdown. + expect(container.querySelector('[data-testid="project-files-scroll"]')).not.toBeNull() + expect(container.querySelector('[aria-label="Local file browser"]')).toBeNull() + + const openMenu = async (): Promise => { + const filterButton = container.querySelector( + '[aria-label="Filter project files"]' + ) + await act(async () => { + filterButton?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + filterButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + } + + await openMenu() + const deviceItem = Array.from( + document.querySelectorAll('[role="menuitemradio"]') + ).find((el) => el.textContent?.includes('TychoStation')) + await act(async () => { + deviceItem?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + // The local browser replaces the artifacts list inside the same Files tab. + expect(container.querySelector('[aria-label="Local file browser"]')).not.toBeNull() + expect(container.querySelector('[data-testid="project-files-scroll"]')).toBeNull() + expect(window.api.localFs.listDir).toHaveBeenCalledWith('/Users/roxi') + expect(container.textContent).toContain('Projects') + expect(container.textContent).toContain('notes.md') + // Header count follows the visible container. + expect(container.textContent).toContain('2 files') + + // Picking an artifact scope returns the body to the artifacts list. + await openMenu() + const allArtifacts = document.querySelector('[data-filter-id="all"]') + await act(async () => { + allArtifacts?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(container.querySelector('[data-testid="project-files-scroll"]')).not.toBeNull() + expect(container.querySelector('[aria-label="Local file browser"]')).toBeNull() + }) + + it('closes the local browser Go-to menu on an outside click', async () => { + await renderFilesView() + + const filterButton = container.querySelector( + '[aria-label="Filter project files"]' + ) + await act(async () => { + filterButton?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + filterButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + const deviceItem = Array.from( + document.querySelectorAll('[role="menuitemradio"]') + ).find((el) => el.textContent?.includes('TychoStation')) + await act(async () => { + deviceItem?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + const goTo = Array.from(container.querySelectorAll('button')).find((el) => + el.textContent?.includes('Go to') + ) + await act(async () => { + goTo?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + goTo?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(document.querySelector('[role="menu"]')).not.toBeNull() + expect(document.body.textContent).toContain('Home') + + // Radix dismisses on pointerdown-then-click outside the content. + await act(async () => { + document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(document.querySelector('[role="menu"]')).toBeNull() + }) + + it('only re-lists the directory when the address bar path actually changes', async () => { + await renderFilesView() + + const filterButton = container.querySelector( + '[aria-label="Filter project files"]' + ) + await act(async () => { + filterButton?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + filterButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + const deviceItem = Array.from( + document.querySelectorAll('[role="menuitemradio"]') + ).find((el) => el.textContent?.includes('TychoStation')) + await act(async () => { + deviceItem?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + const listDir = window.api.localFs.listDir as ReturnType + const callsAfterLanding = listDir.mock.calls.length + const address = container.querySelector('[aria-label="Directory path"]') + expect(address?.value).toBe('/Users/roxi') + + // React tracks the value setter, so drive it natively to make onChange fire. + const typePath = async (next: string): Promise => { + await act(async () => { + if (!address) return + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value' + )?.set?.bind(address) + setter?.(next) + address.dispatchEvent(new Event('input', { bubbles: true })) + }) + } + + // Blurring an untouched path, and a no-op edit (trailing slash), both skip the listing call. + await act(async () => { + address?.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) + }) + expect(listDir.mock.calls.length).toBe(callsAfterLanding) + + await typePath('/Users/roxi/') + await act(async () => { + address?.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) + }) + expect(listDir.mock.calls.length).toBe(callsAfterLanding) + // The field snaps back to the canonical path rather than keeping the equivalent spelling. + expect(address?.value).toBe('/Users/roxi') + + // A genuinely different path does re-read. + listDir.mockResolvedValueOnce({ + entries: [], + resolvedPath: '/Users/roxi/Projects', + truncated: false + }) + await typePath('/Users/roxi/Projects') + await act(async () => { + address?.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) + }) + expect(listDir).toHaveBeenLastCalledWith('/Users/roxi/Projects') + expect(listDir.mock.calls.length).toBe(callsAfterLanding + 1) + }) + + it('disables unreachable hosts in the Remote section', async () => { useComputeStore.setState({ ...createInitialComputeState(), isLoaded: true, diff --git a/src/renderer/src/pages/workspace/ProjectFilesView.tsx b/src/renderer/src/pages/workspace/ProjectFilesView.tsx index 73fb534da..2b47e749c 100644 --- a/src/renderer/src/pages/workspace/ProjectFilesView.tsx +++ b/src/renderer/src/pages/workspace/ProjectFilesView.tsx @@ -6,6 +6,7 @@ import { Folder, LayoutGrid, List, + Monitor, Paperclip, Plus, Search, @@ -53,6 +54,7 @@ import { createPreviewFileItem } from './preview-file-item' import type { MessageArtifact } from './preview-file-item' import { FilePreviewDialog } from './FilePreviewDialog' import { FileBrowserModal } from '../settings/FileBrowserModal' +import { LocalFileBrowser } from './LocalFileBrowser' import { getPreviewThumbnailReadEncoding } from './preview-support' import { createKeyedRequestReader } from './project-file-preview-queue' import { isUnavailableFileError, FILE_MISSING_TAG } from './previews/preview-errors' @@ -770,7 +772,10 @@ const ProjectFilesFilterMenu = ({ canLoadMoreOptions, optionsLoadError, onLoadMoreOptions, - onBrowseRemoteHost + onBrowseRemoteHost, + onBrowseLocal, + localMachineName, + isLocalSelected }: { label: string options: ProjectFilesFilterOption[] @@ -783,6 +788,9 @@ const ProjectFilesFilterMenu = ({ optionsLoadError?: string onLoadMoreOptions: () => void onBrowseRemoteHost: (providerId: string) => void + onBrowseLocal: () => void + localMachineName: string | undefined + isLocalSelected: boolean }): React.JSX.Element => { const hosts = useComputeStore((state) => state.hosts) const openSettingsToCompute = useSettingsStore((state) => state.openSettingsToCompute) @@ -808,10 +816,18 @@ const ProjectFilesFilterMenu = ({ className="max-w-[220px] gap-1.5" aria-label="Filter project files" > - + {isLocalSelected ? ( +