From 10070213a6a34b9cd360673257a82e83f59a9889 Mon Sep 17 00:00:00 2001 From: Roxi Date: Tue, 28 Jul 2026 09:24:43 +0800 Subject: [PATCH 01/14] feat(files): add "This computer" local file browser Add a local-filesystem sibling to the remote SSH browser, opening local files as preview-workbench tabs through the shared preview pipeline. - shared/local-fs: pure types + validation, sensitive-path detection, dir sorting, bookmark key - main/local-fs: LocalFsService (list/preview/roots/reveal/openPath) over node:fs, entry-capped, realpath-canonicalized; reused by the managed preview resolver so local images/PDFs stream through validated paths - bridge: window.api.localFs (5 methods); 'local' added to PreviewFileSource / ManagedPreviewSource; regenerated web api map - UI: LocalFileBrowser (back/up/refresh, editable address bar, Go-to bookmarks, sensitive-file/dir confirm), LocalFileHeaderActions (reveal / copy path / open-with), wired into the Files-panel Artifacts dropdown Sensitive-path warning covers credential dirs (.ssh/.aws/.gnupg) on entry plus suffix-less secret files (SSH keys, cloud credentials). Co-Authored-By: Claude Opus 4.8 --- docs/local-artifacts-mockup.html | 289 +++++++++++ src/main/ipc.ts | 18 +- src/main/local-fs/ipc.ts | 33 ++ src/main/local-fs/service.test.ts | 77 +++ src/main/local-fs/service.ts | 98 ++++ src/preload/index.d.ts | 13 + src/preload/index.ts | 22 + .../src/pages/workspace/LocalFileBrowser.tsx | 472 ++++++++++++++++++ .../workspace/LocalFileHeaderActions.tsx | 116 +++++ .../pages/workspace/PreviewFileSurface.tsx | 21 +- .../src/pages/workspace/ProjectFilesView.tsx | 29 +- .../src/pages/workspace/preview-file-item.ts | 31 ++ .../workspace/previews/PreviewFallback.tsx | 19 +- .../workspace/previews/preview-file-reader.ts | 7 +- .../previews/renderers/OfficePreview.tsx | 36 +- .../src/stores/preview-workbench-store.ts | 6 +- src/renderer/web/api-map.generated.ts | 5 + src/shared/local-fs.test.ts | 101 ++++ src/shared/local-fs.ts | 101 ++++ src/shared/preview-resources.ts | 4 +- 20 files changed, 1467 insertions(+), 31 deletions(-) create mode 100644 docs/local-artifacts-mockup.html create mode 100644 src/main/local-fs/ipc.ts create mode 100644 src/main/local-fs/service.test.ts create mode 100644 src/main/local-fs/service.ts create mode 100644 src/renderer/src/pages/workspace/LocalFileBrowser.tsx create mode 100644 src/renderer/src/pages/workspace/LocalFileHeaderActions.tsx create mode 100644 src/shared/local-fs.test.ts create mode 100644 src/shared/local-fs.ts 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/ipc.ts b/src/main/ipc.ts index 8c9b5ef71..9466e618c 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -45,6 +45,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 @@ -85,6 +86,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' @@ -221,14 +224,18 @@ const registerIpcHandlers = async ({ const artifactRunRegistry = new ArtifactRunRegistry() // Share one upload repository so composer staging, prompt finalization, and previews agree. const uploadRepository = createDefaultUploadRepository() + // 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', + source: ManagedPreviewSource, request: { path: string } - ): Promise => - source === 'artifact' - ? artifactRepository.resolveManagedFilePath(request) - : uploadRepository.resolveManagedUploadPath(request) + ): Promise => { + if (source === 'artifact') return artifactRepository.resolveManagedFilePath(request) + if (source === 'upload') return uploadRepository.resolveManagedUploadPath(request) + return localFsService.resolveFilePath(request) + } // One registry owns short-lived capability URLs for both managed artifact repositories. const previewResources = new ManagedPreviewResources({ resolvePath: resolveManagedFilePath @@ -711,6 +718,7 @@ const registerIpcHandlers = async ({ runtimeRef.current ? runtimeRef.current.getActiveArtifactRunIds() : [] ) registerUploadIpcHandlers(uploadRepository) + 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..1368a07cf --- /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 { + owner = '' + } + 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/preload/index.d.ts b/src/preload/index.d.ts index 5b26974e9..a9abead0c 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -39,6 +39,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 { @@ -447,6 +448,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 getReference(request: NotebookSessionRequest): Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 514e4fa92..dbcdbae97 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -41,6 +41,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 { @@ -493,6 +494,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 // Resolves an existing notebook entry for a session without creating one, or null when absent. @@ -1026,6 +1039,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/workspace/LocalFileBrowser.tsx b/src/renderer/src/pages/workspace/LocalFileBrowser.tsx new file mode 100644 index 000000000..d640dda4c --- /dev/null +++ b/src/renderer/src/pages/workspace/LocalFileBrowser.tsx @@ -0,0 +1,472 @@ +// Local ("This computer") file browser modal. Opened from the Files-panel Artifacts dropdown's +// "This computer" entry. Forked from the remote FileBrowserModal chrome (back/up/refresh, 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 +// - 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, + ArrowUp, + Bookmark, + ChevronDown, + Folder, + File, + Home, + MapPin, + RefreshCw, + X +} from 'lucide-react' +import { Dialog } from 'radix-ui' +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { LocalDirEntry, LocalRoots } from '../../../../shared/local-fs' +import { + isSensitiveLocalPath, + LOCAL_BOOKMARKS_KEY, + resolveLocalPath, + validateLocalPath +} from '../../../../shared/local-fs' +import { Button } from '@/components/ui/button' +import { dialogOverlayClassName, dialogPanelClassName } from '@/components/ui/dialog-chrome' +import { cn } from '@/lib/utils' +import { usePreviewWorkbenchStore } from '@/stores/preview-workbench-store' + +import { createPreviewFileItemFromLocal, LOCAL_PREVIEW_SESSION_ID } from './preview-file-item' + +// 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) +} + +type BrowserState = + | { kind: 'loading' } + | { kind: 'ok'; entries: LocalDirEntry[]; resolvedPath: string; truncated: boolean } + | { kind: 'error'; detail: string } + +// Go-to dropdown: Home is fixed at the top (never removable); pinned folders follow with Unpin (✕). +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 => ( +
+ {home ? ( + + ) : null} + {!isBookmarked && currentPath ? ( + + ) : null} + {bookmarks.length > 0 ? ( + <> +
+ Pinned +
+ {bookmarks.map((path) => ( +
+ + +
+ ))} + + ) : 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') { + return ( +
+ {state.detail} +
+ ) + } + 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 = ({ + open, + onClose +}: { + open: boolean + onClose: () => void +}): React.JSX.Element => { + const [roots, setRoots] = useState(null) + const [cwd, setCwd] = useState('') + const [state, setState] = useState({ kind: 'loading' }) + const [history, setHistory] = useState([]) + const [addressInput, setAddressInput] = useState('') + const [addressEditing, setAddressEditing] = useState(false) + const [bookmarks, setBookmarks] = useState([]) + const [gotoOpen, setGotoOpen] = useState(false) + const upsertAndActivateItem = usePreviewWorkbenchStore((s) => s.upsertAndActivateItem) + // Mirrors cwd so navigate() can read the previous location without depending on it (keeps the + // callback identity stable) and without side effects inside a state updater (StrictMode-safe). + const cwdRef = useRef('') + + // Loads one directory; pushes the previous cwd onto history unless replacing (back/refresh). + const navigate = useCallback(async (target: string, pushHistory = true): Promise => { + setState({ kind: 'loading' }) + try { + const listing = await window.api.localFs.listDir(target) + const previous = cwdRef.current + if (pushHistory && previous && previous !== listing.resolvedPath) { + setHistory((h) => [...h, previous]) + } + cwdRef.current = listing.resolvedPath + setState({ + kind: 'ok', + entries: listing.entries, + resolvedPath: listing.resolvedPath, + truncated: listing.truncated + }) + setCwd(listing.resolvedPath) + setAddressInput(listing.resolvedPath) + } catch (err) { + setState({ kind: 'error', detail: (err as Error).message ?? 'Failed to list directory.' }) + } + }, []) + + // On open: fetch roots + bookmarks, then land in Home. + useEffect(() => { + if (!open) return + void (async () => { + const [fetchedRoots, fetchedBookmarks] = await Promise.all([ + window.api.localFs.getRoots(), + window.api.compute.bookmarksGet(LOCAL_BOOKMARKS_KEY) + ]) + setRoots(fetchedRoots) + setBookmarks(fetchedBookmarks) + setHistory([]) + cwdRef.current = '' + await navigate(fetchedRoots.home, false) + })() + }, [open, navigate]) + + const listing = state.kind === 'ok' ? state : null + const currentPath = listing?.resolvedPath ?? cwd + const isAtRoot = currentPath === '/' + + const handleBack = (): void => { + const prev = history[history.length - 1] + if (!prev) return + setHistory((h) => h.slice(0, -1)) + void navigate(prev, false) + } + + const handleAddressSubmit = (e: React.FormEvent): void => { + e.preventDefault() + const resolved = resolveLocalPath(currentPath, addressInput.trim()) + if (validateLocalPath(resolved)) { + setState({ + kind: 'error', + detail: 'Path must be absolute and contain no control characters.' + }) + return + } + setAddressEditing(false) + 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 + }) + ) + onClose() + } + + 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 ( + { + if (!o) onClose() + }} + > + + + + {/* Header: machine chip + close */} +
+ + This computer + + {roots ? ( + + + ) : null} +
+ + + +
+ + {/* Toolbar */} +
+ + + + {/* Go-to dropdown: fixed Home + pinned bookmarks */} +
+ + {gotoOpen ? ( + { + setGotoOpen(false) + void navigate(path) + }} + onPinCurrent={() => { + setGotoOpen(false) + void handleToggleBookmark() + }} + onRemoveBookmark={(path) => void handleRemoveBookmark(path)} + /> + ) : null} +
+ + {/* Editable address bar */} +
+ {addressEditing ? ( + setAddressInput(e.target.value)} + onBlur={handleAddressSubmit} + className="w-full rounded-md border border-border bg-muted px-2 py-1 font-mono text-xs outline-none 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..7eedd0bad --- /dev/null +++ b/src/renderer/src/pages/workspace/LocalFileHeaderActions.tsx @@ -0,0 +1,116 @@ +// Action cluster shown in the preview header for local ("This computer") files, replacing the +// artifact/upload "Download" button. Local files already live on disk, so the actions are +// filesystem-oriented: Reveal in Finder (primary) + a "…" menu (Reveal / Copy path / Open with +// default app). Kept in its own module so PreviewFileSurface stays source-neutral. +import { Check, ClipboardCopy, ExternalLink, FolderOpen, MoreHorizontal } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' + +// 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 => ( + +) + +export const LocalFileHeaderActions = ({ + path, + tooltipClassName +}: { + path: string + tooltipClassName?: string +}): React.JSX.Element => { + const [copied, setCopied] = useState(false) + const copiedTimer = useRef>(undefined) + + // Clear any pending "Copied" reset when the header unmounts (tab closed within the 1.5s window). + useEffect(() => () => clearTimeout(copiedTimer.current), []) + + const reveal = (): void => { + void window.api.localFs.reveal(path) + } + const copyPath = async (): Promise => { + await navigator.clipboard.writeText(path) + setCopied(true) + clearTimeout(copiedTimer.current) + copiedTimer.current = setTimeout(() => setCopied(false), 1500) + } + const openWithDefault = (): void => { + void window.api.localFs.openPath(path) + } + + return ( + <> + + + + + + Reveal in Finder + + + + + + + + + + void copyPath()} className="gap-2"> + {copied ? ( + + + + + + + ) +} diff --git a/src/renderer/src/pages/workspace/PreviewFileSurface.tsx b/src/renderer/src/pages/workspace/PreviewFileSurface.tsx index e3867f70d..70c522b06 100644 --- a/src/renderer/src/pages/workspace/PreviewFileSurface.tsx +++ b/src/renderer/src/pages/workspace/PreviewFileSurface.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import type { PreviewFileItem } from '@/stores/preview-workbench-store' +import { LocalFileHeaderActions } from './LocalFileHeaderActions' import { ManagedFileDownloadButton } from './ManagedFileDownloadButton' import { PreviewFileContent } from './previews/PreviewFileContent' @@ -58,15 +59,21 @@ const PreviewFileHeader = ({
- {item.title} + + {item.source === 'local' ? item.path : item.title} + - + {item.source === 'local' ? ( + + ) : ( + + )} {onOpenFullScreen ? ( diff --git a/src/renderer/src/pages/workspace/ProjectFilesView.tsx b/src/renderer/src/pages/workspace/ProjectFilesView.tsx index 358e9c877..f817b68ef 100644 --- a/src/renderer/src/pages/workspace/ProjectFilesView.tsx +++ b/src/renderer/src/pages/workspace/ProjectFilesView.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronDown, File, Folder, Paperclip, Plus, Server } from 'lucide-react' +import { Check, ChevronDown, File, Folder, Monitor, Paperclip, Plus, Server } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { @@ -35,6 +35,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' @@ -567,7 +568,8 @@ const ProjectFilesFilterMenu = ({ onSelect, canLoadMoreOptions, onLoadMoreOptions, - onBrowseRemoteHost + onBrowseRemoteHost, + onBrowseLocal }: { label: string options: ProjectFilesFilterOption[] @@ -576,6 +578,7 @@ const ProjectFilesFilterMenu = ({ canLoadMoreOptions: boolean onLoadMoreOptions: () => void onBrowseRemoteHost: (providerId: string) => void + onBrowseLocal: () => void }): React.JSX.Element => { const hosts = useComputeStore((state) => state.hosts) const openSettingsToCompute = useSettingsStore((state) => state.openSettingsToCompute) @@ -623,6 +626,25 @@ const ProjectFilesFilterMenu = ({ ))} + {/* LOCAL section: browse files on the machine Kiro runs on */} + + LOCAL + + onBrowseLocal()}> + + + + + {/* REMOTE section: SSH compute hosts */} REMOTE @@ -785,6 +807,7 @@ const ProjectFilesViewContent = ({ // Remote file browser modal state — set to a providerId when a REMOTE host is selected. const [browseProviderId, setBrowseProviderId] = useState(undefined) + const [localBrowserOpen, setLocalBrowserOpen] = useState(false) const handleIndexChanged = useCallback( (event: ProjectFilesChangedEvent): void => { const currentSessions = useSessionStore.getState().sessions @@ -1102,6 +1125,7 @@ const ProjectFilesViewContent = ({ canLoadMoreOptions={Boolean(index.groups.nextCursor) && !index.groups.isLoading} onLoadMoreOptions={() => void index.loadMoreGroups()} onBrowseRemoteHost={(providerId) => setBrowseProviderId(providerId)} + onBrowseLocal={() => setLocalBrowserOpen(true)} />
{visibleFileCount} files
@@ -1268,6 +1292,7 @@ const ProjectFilesViewContent = ({ onClose={() => setBrowseProviderId(undefined)} initialProviderId={browseProviderId} /> + setLocalBrowserOpen(false)} /> ) } diff --git a/src/renderer/src/pages/workspace/preview-file-item.ts b/src/renderer/src/pages/workspace/preview-file-item.ts index bd09d44c8..ca56d70db 100644 --- a/src/renderer/src/pages/workspace/preview-file-item.ts +++ b/src/renderer/src/pages/workspace/preview-file-item.ts @@ -89,6 +89,37 @@ export const createPreviewFileItemFromUpload = ( }) } +// Sentinel session id for local ("This computer") preview tabs. Local files belong to no chat +// session, so they use a stable non-session key (mirrors the project-files tool's sentinel) — this +// keeps them out of removeSessionItems cleanup when a real session is deleted. +export const LOCAL_PREVIEW_SESSION_ID = '__local_files__' + +// Builds a preview tab for a local ("This computer") file. The path is an absolute filesystem +// path; the id is namespaced by path so re-opening the same file re-activates its tab. sessionId +// scopes the tab to the active session like every other preview item. +export const createPreviewFileItemFromLocal = ({ + sessionId, + path, + name, + size, + mtimeMs +}: { + sessionId: string + path: string + name: string + size?: number + mtimeMs?: number +}): PreviewFileItem => + createPreviewFileItem({ + id: `local:${path}`, + sessionId, + source: 'local', + path, + name, + size, + mtimeMs + }) + // Converts a sent-message artifact mention into the same preview shape used by its source panel. export const createPreviewFileItemFromMention = ( part: ArtifactMentionPart, diff --git a/src/renderer/src/pages/workspace/previews/PreviewFallback.tsx b/src/renderer/src/pages/workspace/previews/PreviewFallback.tsx index 80acac063..8730ea5be 100644 --- a/src/renderer/src/pages/workspace/previews/PreviewFallback.tsx +++ b/src/renderer/src/pages/workspace/previews/PreviewFallback.tsx @@ -4,6 +4,7 @@ import type { LucideIcon } from 'lucide-react' import { Button } from '@/components/ui/button' import type { PreviewFileFormat, PreviewFileSource } from '@/stores/preview-workbench-store' +import { LocalFileFallbackAction } from '../LocalFileHeaderActions' import { ManagedFileDownloadButton } from '../ManagedFileDownloadButton' import { getFileExtension } from '../preview-support' import { @@ -247,13 +248,17 @@ export const PreviewUnsupportedContent = ({

This file type isn't supported for preview

- + {source === 'local' ? ( + + ) : ( + + )} {name} diff --git a/src/renderer/src/pages/workspace/previews/preview-file-reader.ts b/src/renderer/src/pages/workspace/previews/preview-file-reader.ts index 5858fec82..727ec24d0 100644 --- a/src/renderer/src/pages/workspace/previews/preview-file-reader.ts +++ b/src/renderer/src/pages/workspace/previews/preview-file-reader.ts @@ -7,8 +7,11 @@ import type { PreviewFileSource } from '@/stores/preview-workbench-store' type PreviewFileReader = (request: ReadArtifactPreviewRequest) => Promise // Selects the managed IPC reader once so callers remain source-neutral. -const getPreviewFileReader = (source: PreviewFileSource = 'artifact'): PreviewFileReader => - source === 'upload' ? window.api.uploads.readPreview : window.api.artifacts.readPreview +const getPreviewFileReader = (source: PreviewFileSource = 'artifact'): PreviewFileReader => { + if (source === 'upload') return window.api.uploads.readPreview + if (source === 'local') return window.api.localFs.readPreview + return window.api.artifacts.readPreview +} export { getPreviewFileReader } export type { PreviewFileReader } diff --git a/src/renderer/src/pages/workspace/previews/renderers/OfficePreview.tsx b/src/renderer/src/pages/workspace/previews/renderers/OfficePreview.tsx index 7a5a4bf15..307bd0f10 100644 --- a/src/renderer/src/pages/workspace/previews/renderers/OfficePreview.tsx +++ b/src/renderer/src/pages/workspace/previews/renderers/OfficePreview.tsx @@ -6,7 +6,8 @@ import type { OfficePreviewErrorCode, OfficePreviewHostMessage, OfficePreviewRequestedExtension, - OfficePreviewRuntimeState + OfficePreviewRuntimeState, + OfficePreviewSource } from '../../../../../../shared/office-preview' import { isOfficePreviewRuntimeMessage, @@ -15,6 +16,7 @@ import { OFFICE_PREVIEW_RUNTIME_ORIGIN } from '../../../../../../shared/office-preview' +import { LocalFileFallbackAction } from '../../LocalFileHeaderActions' import { ManagedFileDownloadButton } from '../../ManagedFileDownloadButton' import { PreviewFallbackCard, PreviewLoadingContent } from '../PreviewFallback' import { usePreviewRuntime } from '../preview-runtime-context' @@ -64,7 +66,7 @@ const OfficeDownloadFallback = ({ message }: { item: PreviewFileItem - source: PreviewFileSource + source: OfficePreviewSource title: string message: string }): React.JSX.Element => ( @@ -103,12 +105,14 @@ type OfficePreviewFrame = { } // Owns isolated iframe coordination; Office bytes and vendor libraries stay in the child runtime. -export const OfficePreviewContent = ({ +// Only artifact/upload sources flow here — local files never reach the LibreOffice pipeline (see +// the OfficePreviewContent wrapper below). +const RemoteOfficePreviewContent = ({ item, - source = 'artifact' + source }: { item: PreviewFileItem - source?: PreviewFileSource + source: OfficePreviewSource }): React.JSX.Element => { const hostId = useId() const frameRef = useRef(null) @@ -326,6 +330,28 @@ export const OfficePreviewContent = ({ ) } +// Source-aware entry: local Office files skip the in-app LibreOffice pipeline (which resolves only +// managed artifact/upload paths) and offer to open in the OS default app instead. +export const OfficePreviewContent = ({ + item, + source = 'artifact' +}: { + item: PreviewFileItem + source?: PreviewFileSource +}): React.JSX.Element => { + if (source === 'local') { + return ( + } + /> + ) + } + return +} + export const OfficePreviewRenderer = ({ item }: PreviewFileRendererProps): React.JSX.Element => ( ) diff --git a/src/renderer/src/stores/preview-workbench-store.ts b/src/renderer/src/stores/preview-workbench-store.ts index 6a6e202e4..d80a44843 100644 --- a/src/renderer/src/stores/preview-workbench-store.ts +++ b/src/renderer/src/stores/preview-workbench-store.ts @@ -20,8 +20,10 @@ export type PreviewFileFormat = | 'spreadsheet' | 'presentation' | 'unknown' -// Distinguishes generated artifacts from user uploads when preview readers and actions differ. -export type PreviewFileSource = 'artifact' | 'upload' +// Distinguishes generated artifacts from user uploads and local ("This computer") files when +// preview readers and header actions differ. 'local' files live outside app storage: their path is +// an absolute filesystem path read via window.api.localFs. +export type PreviewFileSource = 'artifact' | 'upload' | 'local' export const PROJECT_FILES_PREVIEW_ID = 'tool:project:files' type PreviewItemBase = { diff --git a/src/renderer/web/api-map.generated.ts b/src/renderer/web/api-map.generated.ts index dcf03c9ae..ab4116dd9 100644 --- a/src/renderer/web/api-map.generated.ts +++ b/src/renderer/web/api-map.generated.ts @@ -43,6 +43,11 @@ export const WEB_INVOKE_CHANNELS = { 'compute.sshConfigAliases': 'compute:ssh-config-aliases', 'github.getStars': 'github:get-stars', 'lifecycle.getClientId': 'lifecycle:client-id', + 'localFs.getRoots': 'local-fs:get-roots', + 'localFs.listDir': 'local-fs:list-dir', + 'localFs.openPath': 'local-fs:open-path', + 'localFs.readPreview': 'local-fs:read-preview', + 'localFs.reveal': 'local-fs:reveal', 'logs.getPath': 'logs:get-path', 'logs.openFile': 'logs:open-file', 'logs.revealInFolder': 'logs:reveal-in-folder', diff --git a/src/shared/local-fs.test.ts b/src/shared/local-fs.test.ts new file mode 100644 index 000000000..886cb4e90 --- /dev/null +++ b/src/shared/local-fs.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' + +import { + isSensitiveLocalPath, + resolveLocalPath, + sortLocalEntries, + validateLocalPath, + type LocalDirEntry +} from './local-fs' + +describe('validateLocalPath', () => { + it('accepts absolute paths', () => { + expect(validateLocalPath('/Users/roxi/Documents')).toBeUndefined() + expect(validateLocalPath('/')).toBeUndefined() + }) + + it('rejects non-absolute or empty input', () => { + expect(validateLocalPath('relative/path')).toBe('not_absolute') + expect(validateLocalPath('')).toBe('not_absolute') + // @ts-expect-error runtime guard for non-string IPC input + expect(validateLocalPath(undefined)).toBe('not_absolute') + }) + + it('rejects paths with control characters', () => { + expect(validateLocalPath('/Users/roxi/\x00evil')).toBe('control_chars') + expect(validateLocalPath('/Users/roxi/\x1ffile')).toBe('control_chars') + }) +}) + +describe('isSensitiveLocalPath', () => { + it('flags credential dirs and files', () => { + expect(isSensitiveLocalPath('/Users/roxi/.ssh')).toBe(true) + expect(isSensitiveLocalPath('/Users/roxi/project/.env')).toBe(true) + expect(isSensitiveLocalPath('/Users/roxi/.env.local')).toBe(true) + expect(isSensitiveLocalPath('/etc/ssl/private/server.key')).toBe(true) + expect(isSensitiveLocalPath('/Users/roxi/cert.pem')).toBe(true) + }) + + it('flags suffix-less secret files (SSH keys, cloud credentials)', () => { + expect(isSensitiveLocalPath('/Users/roxi/.ssh/id_rsa')).toBe(true) + expect(isSensitiveLocalPath('/Users/roxi/.ssh/id_ed25519')).toBe(true) + expect(isSensitiveLocalPath('/Users/roxi/.aws/credentials')).toBe(true) + expect(isSensitiveLocalPath('/Users/roxi/.pgpass')).toBe(true) + expect(isSensitiveLocalPath('/Users/roxi/keystore.p12')).toBe(true) + // case-insensitive on the basename + expect(isSensitiveLocalPath('/Users/roxi/.ssh/ID_RSA')).toBe(true) + }) + + it('does not flag lookalikes that are not secrets', () => { + expect(isSensitiveLocalPath('/Users/roxi/.ssh/id_rsa.pub')).toBe(false) + expect(isSensitiveLocalPath('/Users/roxi/credentials.md')).toBe(false) + }) + + it('treats ordinary files as non-sensitive', () => { + expect(isSensitiveLocalPath('/Users/roxi/Documents/notes.md')).toBe(false) + expect(isSensitiveLocalPath('/Users/roxi/data.csv')).toBe(false) + expect(isSensitiveLocalPath('/')).toBe(false) + }) +}) + +describe('sortLocalEntries', () => { + it('orders directories first, then case-insensitive alphabetical', () => { + const entries: LocalDirEntry[] = [ + { name: 'zebra.txt', isDirectory: false, size: 1, mtimeMs: 0 }, + { name: 'Apple', isDirectory: true, size: 0, mtimeMs: 0 }, + { name: 'banana.md', isDirectory: false, size: 2, mtimeMs: 0 }, + { name: 'apricot', isDirectory: true, size: 0, mtimeMs: 0 } + ] + expect(sortLocalEntries(entries).map((e) => e.name)).toEqual([ + 'Apple', + 'apricot', + 'banana.md', + 'zebra.txt' + ]) + }) + + it('does not mutate the input array', () => { + const entries: LocalDirEntry[] = [ + { name: 'b', isDirectory: false, size: 0, mtimeMs: 0 }, + { name: 'a', isDirectory: false, size: 0, mtimeMs: 0 } + ] + sortLocalEntries(entries) + expect(entries.map((e) => e.name)).toEqual(['b', 'a']) + }) +}) + +describe('resolveLocalPath', () => { + it('returns absolute input unchanged', () => { + expect(resolveLocalPath('/Users/roxi', '/etc/hosts')).toBe('/etc/hosts') + }) + + it('joins relative input onto cwd', () => { + expect(resolveLocalPath('/Users/roxi', 'Documents')).toBe('/Users/roxi/Documents') + expect(resolveLocalPath('/Users/roxi/', 'Documents')).toBe('/Users/roxi/Documents') + expect(resolveLocalPath('/', 'etc')).toBe('/etc') + }) + + it('returns cwd for empty input', () => { + expect(resolveLocalPath('/Users/roxi', '')).toBe('/Users/roxi') + }) +}) diff --git a/src/shared/local-fs.ts b/src/shared/local-fs.ts new file mode 100644 index 000000000..07108f328 --- /dev/null +++ b/src/shared/local-fs.ts @@ -0,0 +1,101 @@ +// Shared types and pure helpers for the local ("This computer") file-browser feature. +// Mirrors the remote-fs contracts (src/shared/remote-fs.ts) but for the machine Kiro runs on. +// No I/O here: everything is pure and directly unit-testable. Main-process I/O lives in +// src/main/local-fs/, the renderer talks to it via window.api.localFs. + +// A single entry returned by a local directory listing (files and directories). +export type LocalDirEntry = { + name: string + isDirectory: boolean + // File size in bytes; directories report 0. + size: number + // Modification time in milliseconds. + mtimeMs: number +} + +// Result of a listDir call: the entries plus navigation context. +export type LocalDirListing = { + // Sorted: directories first, then files, each group alphabetical (case-insensitive). + entries: LocalDirEntry[] + // True when the directory had more entries than the cap and was truncated. + truncated: boolean + // Server-side realpath of the requested path (resolves .. and symlinks). + resolvedPath: string +} + +// Well-known roots + a user-facing machine name, inlined for the browser's "Go to" dropdown and +// the Artifacts entry label. +export type LocalRoots = { + home: string + // Friendly machine name (e.g. "Roxi's MacBook Pro"), derived from os.hostname(). + machineName: string +} + +// The single settings key under computeBookmarks reserved for local (non-SSH) bookmarks. Reusing +// the compute bookmark store avoids a settings-schema migration; SSH providers are keyed by +// provider_id, which never collides with this literal. +export const LOCAL_BOOKMARKS_KEY = 'local' + +// Max directory entries returned by a single listDir before truncation kicks in. Keeps the +// renderer responsive on huge directories (e.g. node_modules). +export const LOCAL_DIR_ENTRY_CAP = 5000 + +// Validates a local path before touching the filesystem. Returns an error kind or undefined. +// The security model is "Home start, full-disk navigable": we do NOT restrict to a root, but we +// reject non-absolute paths and paths containing ASCII control characters (which are never valid +// path components and would indicate a crafted/garbled input). +export const validateLocalPath = (path: string): 'not_absolute' | 'control_chars' | undefined => { + if (typeof path !== 'string' || path.length === 0 || !path.startsWith('/')) return 'not_absolute' + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f]/.test(path)) return 'control_chars' + return undefined +} + +// Basenames that are always considered "sensitive" — reading them (or, for directories, entering +// them) is allowed per the chosen security model, but the UI surfaces a gentle warning first. +// Matched case-insensitively against the final path component. Covers credential directories +// (.ssh/.aws/.gnupg — the browser warns before entering these) and well-known secret files, +// including the SSH private keys and cloud credential files that carry no distinguishing suffix. +const SENSITIVE_BASENAMES = new Set([ + '.ssh', + '.aws', + '.gnupg', + '.env', + '.netrc', + '.npmrc', + '.pgpass', + '.htpasswd', + 'credentials', + 'id_rsa', + 'id_dsa', + 'id_ecdsa', + 'id_ed25519' +]) +const SENSITIVE_SUFFIXES = ['.pem', '.key', '.env', '.p12', '.pfx'] + +// Returns true when a path's basename looks security-sensitive (keys, credentials, dotenv). Pure +// so both the browser listing and the preview open path can share one definition. +export const isSensitiveLocalPath = (path: string): boolean => { + const base = (path.split('/').pop() ?? '').toLowerCase() + if (!base) return false + if (SENSITIVE_BASENAMES.has(base)) return true + if (base.startsWith('.env')) return true + return SENSITIVE_SUFFIXES.some((suffix) => base.endsWith(suffix)) +} + +// Sorts entries directories-first, then case-insensitive alphabetical within each group. Returns a +// new array; does not mutate the input. +export const sortLocalEntries = (entries: LocalDirEntry[]): LocalDirEntry[] => + [...entries].sort((a, b) => { + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + }) + +// Resolves an address-bar input to an absolute path, lexically joining relative input onto cwd. +// The main process still calls realpath to canonicalize '..' and symlinks. +export const resolveLocalPath = (cwd: string, input: string): string => { + if (input.startsWith('/')) return input + if (input === '') return cwd + const base = cwd.endsWith('/') ? cwd.slice(0, -1) : cwd + return `${base}/${input}` +} diff --git a/src/shared/preview-resources.ts b/src/shared/preview-resources.ts index 64662333c..0226c5827 100644 --- a/src/shared/preview-resources.ts +++ b/src/shared/preview-resources.ts @@ -1,4 +1,6 @@ -export type ManagedPreviewSource = 'artifact' | 'upload' +// 'local' streams a file from an arbitrary absolute filesystem path (the "This computer" browser). +// Unlike artifact/upload it is not confined to a storage root; the resolver validates + realpaths it. +export type ManagedPreviewSource = 'artifact' | 'upload' | 'local' export const MANAGED_PREVIEW_LOAD_ERROR = 'open-science-preview-load-error' From 16295e12d91a7b70e137ec66bab25112e0841cea Mon Sep 17 00:00:00 2001 From: Roxi Date: Tue, 28 Jul 2026 10:08:59 +0800 Subject: [PATCH 02/14] refactor(files): drop dead .env basename and redundant owner reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SENSITIVE_BASENAMES: remove '.env' — startsWith('.env') already covers it - buildMachineName: drop the owner='' reset in catch (already initialized) Co-Authored-By: Claude Opus 4.8 --- src/main/local-fs/service.ts | 2 +- src/shared/local-fs.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/local-fs/service.ts b/src/main/local-fs/service.ts index 1368a07cf..924fcdf28 100644 --- a/src/main/local-fs/service.ts +++ b/src/main/local-fs/service.ts @@ -17,7 +17,7 @@ const buildMachineName = (): string => { try { owner = userInfo().username } catch { - owner = '' + // 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 } diff --git a/src/shared/local-fs.ts b/src/shared/local-fs.ts index 07108f328..8795580b7 100644 --- a/src/shared/local-fs.ts +++ b/src/shared/local-fs.ts @@ -60,7 +60,6 @@ const SENSITIVE_BASENAMES = new Set([ '.ssh', '.aws', '.gnupg', - '.env', '.netrc', '.npmrc', '.pgpass', From fca2661b688e256bb866e307c267a32e9d0c3d45 Mon Sep 17 00:00:00 2001 From: Roxi Date: Tue, 28 Jul 2026 15:55:06 +0800 Subject: [PATCH 03/14] feat(files): open "This computer" as a preview tab Render the local file browser as a stable preview-workbench tab instead of a modal, so it lives alongside file and notebook previews. - add a 'local-browser' toolKind + createLocalBrowserPreviewItem factory - refactor LocalFileBrowser from a Dialog to full-height tab content; load roots/bookmarks on mount, drop the open/onClose props and close button - relabel the dropdown section "LOCAL" -> "this computer" and show the resolved device name (falls back to "This computer") - make the address bar an always-on input (no click-to-edit, no folder icon) Also fixes a stale ProjectFilesView test that asserted the old pre-feature behavior. Co-Authored-By: Claude Opus 4.8 --- .../src/pages/workspace/LocalFileBrowser.tsx | 257 ++++++++---------- .../pages/workspace/ProjectFilesView.test.tsx | 5 +- .../src/pages/workspace/ProjectFilesView.tsx | 43 ++- .../previews/PreviewToolContent.test.tsx | 9 + .../workspace/previews/PreviewToolContent.tsx | 3 + .../src/stores/preview-workbench-store.ts | 14 +- 6 files changed, 170 insertions(+), 161 deletions(-) diff --git a/src/renderer/src/pages/workspace/LocalFileBrowser.tsx b/src/renderer/src/pages/workspace/LocalFileBrowser.tsx index d640dda4c..5e6b89f0f 100644 --- a/src/renderer/src/pages/workspace/LocalFileBrowser.tsx +++ b/src/renderer/src/pages/workspace/LocalFileBrowser.tsx @@ -1,6 +1,7 @@ -// Local ("This computer") file browser modal. Opened from the Files-panel Artifacts dropdown's -// "This computer" entry. Forked from the remote FileBrowserModal chrome (back/up/refresh, editable -// address bar, Go-to dropdown with a fixed Home + pin/unpin bookmarks) but: +// Local ("This computer") file browser. Opened from the Files-panel Artifacts dropdown's device +// entry and rendered as a preview-workbench tab (not a modal). Forked from the remote FileBrowserModal +// chrome (back/up/refresh, 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 // - 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 @@ -17,7 +18,6 @@ import { RefreshCw, X } from 'lucide-react' -import { Dialog } from 'radix-ui' import { useCallback, useEffect, useRef, useState } from 'react' import type { LocalDirEntry, LocalRoots } from '../../../../shared/local-fs' @@ -28,7 +28,6 @@ import { validateLocalPath } from '../../../../shared/local-fs' import { Button } from '@/components/ui/button' -import { dialogOverlayClassName, dialogPanelClassName } from '@/components/ui/dialog-chrome' import { cn } from '@/lib/utils' import { usePreviewWorkbenchStore } from '@/stores/preview-workbench-store' @@ -203,19 +202,12 @@ const LocalListing = ({ ) } -export const LocalFileBrowser = ({ - open, - onClose -}: { - open: boolean - onClose: () => void -}): React.JSX.Element => { +export const LocalFileBrowser = (): React.JSX.Element => { const [roots, setRoots] = useState(null) const [cwd, setCwd] = useState('') const [state, setState] = useState({ kind: 'loading' }) const [history, setHistory] = useState([]) const [addressInput, setAddressInput] = useState('') - const [addressEditing, setAddressEditing] = useState(false) const [bookmarks, setBookmarks] = useState([]) const [gotoOpen, setGotoOpen] = useState(false) const upsertAndActivateItem = usePreviewWorkbenchStore((s) => s.upsertAndActivateItem) @@ -246,9 +238,8 @@ export const LocalFileBrowser = ({ } }, []) - // On open: fetch roots + bookmarks, then land in Home. + // On mount: fetch roots + bookmarks, then land in Home. useEffect(() => { - if (!open) return void (async () => { const [fetchedRoots, fetchedBookmarks] = await Promise.all([ window.api.localFs.getRoots(), @@ -260,7 +251,7 @@ export const LocalFileBrowser = ({ cwdRef.current = '' await navigate(fetchedRoots.home, false) })() - }, [open, navigate]) + }, [navigate]) const listing = state.kind === 'ok' ? state : null const currentPath = listing?.resolvedPath ?? cwd @@ -283,7 +274,6 @@ export const LocalFileBrowser = ({ }) return } - setAddressEditing(false) void navigate(resolved) } @@ -310,7 +300,6 @@ export const LocalFileBrowser = ({ mtimeMs: entry.mtimeMs }) ) - onClose() } const isBookmarked = bookmarks.includes(currentPath) @@ -330,143 +319,111 @@ export const LocalFileBrowser = ({ } return ( - { - if (!o) onClose() - }} +
- - - - {/* Header: machine chip + close */} -
- - This computer - - {roots ? ( - - - ) : null} -
- - - -
+ {/* Header: machine chip */} +
+ {roots ? ( + + + ) : null} +
- {/* Toolbar */} -
- - + {/* Toolbar */} +
+ + - {/* Go-to dropdown: fixed Home + pinned bookmarks */} -
- - {gotoOpen ? ( - { - setGotoOpen(false) - void navigate(path) - }} - onPinCurrent={() => { - setGotoOpen(false) - void handleToggleBookmark() - }} - onRemoveBookmark={(path) => void handleRemoveBookmark(path)} - /> - ) : null} -
+ {/* Go-to dropdown: fixed Home + pinned bookmarks */} +
+ + {gotoOpen ? ( + { + setGotoOpen(false) + void navigate(path) + }} + onPinCurrent={() => { + setGotoOpen(false) + void handleToggleBookmark() + }} + onRemoveBookmark={(path) => void handleRemoveBookmark(path)} + /> + ) : null} +
- {/* Editable address bar */} -
- {addressEditing ? ( - setAddressInput(e.target.value)} - onBlur={handleAddressSubmit} - className="w-full rounded-md border border-border bg-muted px-2 py-1 font-mono text-xs outline-none focus:ring-2 focus:ring-ring/50" - aria-label="Directory path" - /> - ) : ( - - )} -
+ {/* Always-editable address bar */} +
+ setAddressInput(e.target.value)} + onBlur={handleAddressSubmit} + spellCheck={false} + className="w-full rounded-md border border-border bg-muted px-2 py-1 font-mono text-xs outline-none focus:ring-2 focus:ring-ring/50" + aria-label="Directory path" + /> +
- - -
+ + +
- {/* Listing */} - - - - + {/* Listing */} + +
) } diff --git a/src/renderer/src/pages/workspace/ProjectFilesView.test.tsx b/src/renderer/src/pages/workspace/ProjectFilesView.test.tsx index 36669ea2d..b5d3c6c04 100644 --- a/src/renderer/src/pages/workspace/ProjectFilesView.test.tsx +++ b/src/renderer/src/pages/workspace/ProjectFilesView.test.tsx @@ -907,7 +907,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', @@ -942,7 +942,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') }) it('uses the global semantic menu surface and hover feedback for filter items', async () => { diff --git a/src/renderer/src/pages/workspace/ProjectFilesView.tsx b/src/renderer/src/pages/workspace/ProjectFilesView.tsx index f817b68ef..18d71a575 100644 --- a/src/renderer/src/pages/workspace/ProjectFilesView.tsx +++ b/src/renderer/src/pages/workspace/ProjectFilesView.tsx @@ -14,6 +14,10 @@ import { Button } from '@/components/ui/button' import { cn, formatByteSize } from '@/lib/utils' import { useNavigationStore } from '@/stores/navigation-store' import type { PreviewFileItem } from '@/stores/preview-workbench-store' +import { + createLocalBrowserPreviewItem, + usePreviewWorkbenchStore +} from '@/stores/preview-workbench-store' import { useSessionStore } from '@/stores/session-store' import { useComputeStore } from '@/stores/compute-store' import { useSettingsStore } from '@/stores/settings-store' @@ -35,7 +39,6 @@ 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' @@ -569,7 +572,8 @@ const ProjectFilesFilterMenu = ({ canLoadMoreOptions, onLoadMoreOptions, onBrowseRemoteHost, - onBrowseLocal + onBrowseLocal, + localMachineName }: { label: string options: ProjectFilesFilterOption[] @@ -579,6 +583,7 @@ const ProjectFilesFilterMenu = ({ onLoadMoreOptions: () => void onBrowseRemoteHost: (providerId: string) => void onBrowseLocal: () => void + localMachineName: string | undefined }): React.JSX.Element => { const hosts = useComputeStore((state) => state.hosts) const openSettingsToCompute = useSettingsStore((state) => state.openSettingsToCompute) @@ -626,9 +631,9 @@ const ProjectFilesFilterMenu = ({ ))} - {/* LOCAL section: browse files on the machine Kiro runs on */} + {/* "this computer" section: browse files on the machine Kiro runs on */} - LOCAL + this computer onBrowseLocal()}>
@@ -1292,7 +1320,6 @@ const ProjectFilesViewContent = ({ onClose={() => setBrowseProviderId(undefined)} initialProviderId={browseProviderId} /> - setLocalBrowserOpen(false)} /> ) } diff --git a/src/renderer/src/pages/workspace/previews/PreviewToolContent.test.tsx b/src/renderer/src/pages/workspace/previews/PreviewToolContent.test.tsx index 45467f9cf..13c2beff9 100644 --- a/src/renderer/src/pages/workspace/previews/PreviewToolContent.test.tsx +++ b/src/renderer/src/pages/workspace/previews/PreviewToolContent.test.tsx @@ -25,6 +25,9 @@ vi.mock('../NotebookPreview', () => ({ vi.mock('../ProjectFilesView', () => ({ ProjectFilesView: (): React.JSX.Element =>
files
})) +vi.mock('../LocalFileBrowser', () => ({ + LocalFileBrowser: (): React.JSX.Element =>
local
+})) vi.mock('../SessionReviewerPanel', () => ({ SessionReviewerPanel: ({ review, @@ -62,6 +65,12 @@ describe('PreviewToolContent', () => { expect(render(createItem({ toolKind: 'files' }))).toContain('data-testid="project-files"') }) + it('routes the "this computer" tool to the local file browser', () => { + expect(render(createItem({ toolKind: 'local-browser' }))).toContain( + 'data-testid="local-browser"' + ) + }) + it('shows the reviewer empty state when the requested session has no reviews', () => { const html = render(createItem({ toolKind: 'reviewer', reviewerSessionId: 'review-session' })) diff --git a/src/renderer/src/pages/workspace/previews/PreviewToolContent.tsx b/src/renderer/src/pages/workspace/previews/PreviewToolContent.tsx index 20d94b28a..a541de510 100644 --- a/src/renderer/src/pages/workspace/previews/PreviewToolContent.tsx +++ b/src/renderer/src/pages/workspace/previews/PreviewToolContent.tsx @@ -4,6 +4,7 @@ import type { PreviewToolItem } from '@/stores/preview-workbench-store' import { NotebookPreview } from '../NotebookPreview' import type { NotebookPreviewItem } from '../NotebookPreview' +import { LocalFileBrowser } from '../LocalFileBrowser' import { ProjectFilesView } from '../ProjectFilesView' import { SessionReviewerPanel } from '../SessionReviewerPanel' @@ -42,6 +43,8 @@ export const PreviewToolContent = ({ return } + if (item.toolKind === 'local-browser') return + if (item.toolKind === 'reviewer') return if (!isNotebookPreviewItem(item)) return null diff --git a/src/renderer/src/stores/preview-workbench-store.ts b/src/renderer/src/stores/preview-workbench-store.ts index d80a44843..b152ac7f4 100644 --- a/src/renderer/src/stores/preview-workbench-store.ts +++ b/src/renderer/src/stores/preview-workbench-store.ts @@ -25,6 +25,7 @@ export type PreviewFileFormat = // an absolute filesystem path read via window.api.localFs. export type PreviewFileSource = 'artifact' | 'upload' | 'local' export const PROJECT_FILES_PREVIEW_ID = 'tool:project:files' +export const LOCAL_BROWSER_PREVIEW_ID = 'tool:local:browser' type PreviewItemBase = { id: string @@ -46,7 +47,7 @@ export type PreviewFileItem = PreviewItemBase & { // Tool previews share the workbench chrome with files, but keep their own render path. export type PreviewToolItem = PreviewItemBase & { type: 'tool' - toolKind?: 'notebook' | 'files' | 'reviewer' + toolKind?: 'notebook' | 'files' | 'reviewer' | 'local-browser' notebook?: NotebookSessionReference // Reviewer-specific: which session's reviews to show, which review to select, and the active // finding to scroll to. @@ -163,6 +164,16 @@ const createProjectFilesPreviewItem = (): PreviewToolItem => ({ title: 'Files' }) +// Builds the stable "This computer" preview tab. The title carries the device name so the tab reads +// like the machine it browses; it falls back to "This computer" before roots resolve. +const createLocalBrowserPreviewItem = (machineName?: string): PreviewToolItem => ({ + id: LOCAL_BROWSER_PREVIEW_ID, + sessionId: '__local_browser__', + type: 'tool', + toolKind: 'local-browser', + title: machineName?.trim() || 'This computer' +}) + // Input for opening the Session reviewer panel; findingId/locator determine scroll position. export type SessionReviewerPreviewInput = { sessionId: string @@ -384,5 +395,6 @@ export const usePreviewWorkbenchStore = create((set, get) export { createNotebookPreviewItem, createProjectFilesPreviewItem, + createLocalBrowserPreviewItem, createSessionReviewerPreviewItem } From 4f8740d0e462c160f5551eddfb63f2c0b5ca15e3 Mon Sep 17 00:00:00 2001 From: Roxi Date: Tue, 28 Jul 2026 17:08:14 +0800 Subject: [PATCH 04/14] refactor(files): make the local browser a container in the Files tab The local browser is no longer its own preview tab. The Files tab now holds two containers and the source dropdown swaps between them: picking an artifact scope shows the artifacts list, picking the device shows the local file browser. Reverts the 'local-browser' toolKind and preview-item factory added in the previous commit. - ProjectFilesView owns a sourceMode ('artifacts' | 'local'); the trigger swaps its icon/label to the device and the entry gets a radio check - the header file count follows the visible container, via an entry-count callback from the browser - drop the browser's machine chip (the source picker carries that name) and give it the same rounded bordered container as the artifact cards - hover rows/menu entries use the light-gray bg-200 surface instead of the gold accent token - the address bar only re-lists when the path actually resolves somewhere new, so blurring an untouched field costs no IPC call; equivalent spellings snap back to the canonical path Co-Authored-By: Claude Opus 4.8 --- .../src/pages/workspace/LocalFileBrowser.tsx | 73 ++-- .../pages/workspace/ProjectFilesView.test.tsx | 119 ++++++ .../src/pages/workspace/ProjectFilesView.tsx | 371 ++++++++++-------- .../previews/PreviewToolContent.test.tsx | 9 - .../workspace/previews/PreviewToolContent.tsx | 3 - .../src/stores/preview-workbench-store.ts | 14 +- 6 files changed, 374 insertions(+), 215 deletions(-) diff --git a/src/renderer/src/pages/workspace/LocalFileBrowser.tsx b/src/renderer/src/pages/workspace/LocalFileBrowser.tsx index 5e6b89f0f..818dcb1a3 100644 --- a/src/renderer/src/pages/workspace/LocalFileBrowser.tsx +++ b/src/renderer/src/pages/workspace/LocalFileBrowser.tsx @@ -1,7 +1,7 @@ -// Local ("This computer") file browser. Opened from the Files-panel Artifacts dropdown's device -// entry and rendered as a preview-workbench tab (not a modal). Forked from the remote FileBrowserModal -// chrome (back/up/refresh, editable address bar, Go-to dropdown with a fixed Home + pin/unpin -// bookmarks) but: +// 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 (back/up/refresh, 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 // - 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 @@ -33,6 +33,13 @@ 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` @@ -91,7 +98,7 @@ const GoToMenu = ({ @@ -173,11 +180,11 @@ const LocalListing = ({ ) : (
    {state.entries.map((entry) => ( -
  • +
  • - - ) : null} - - {index.overviewError ? ( - - ) : null} - - {hasLoadedInitialPages && - index.overview.isIndexComplete && - visibleFileCount === 0 && - !hasPageError ? ( -
    - No files yet -
    - ) : null} - - {(effectiveFilterId === 'all' || effectiveFilterId === 'uploads') && - (index.overview.uploadCount > 0 || Boolean(index.uploads.error)) ? ( -
    - - {!uploadsCollapsed ? ( - <> - {visibleUploadFiles.length > 0 ? ( -
    - {visibleUploadFiles.map((file) => ( - previewFile(file)} + {isLocalMode ? ( + + ) : ( +
    + {!index.overview.isIndexComplete ? ( +
    + + {index.repairError ?? 'Some files could not be indexed yet.'} + + +
    + ) : null} + + {index.overviewError ? ( + + ) : null} + + {hasLoadedInitialPages && + index.overview.isIndexComplete && + visibleFileCount === 0 && + !hasPageError ? ( +
    + No files yet +
    + ) : null} + + {(effectiveFilterId === 'all' || effectiveFilterId === 'uploads') && + (index.overview.uploadCount > 0 || Boolean(index.uploads.error)) ? ( +
    + + {!uploadsCollapsed ? ( + <> + {visibleUploadFiles.length > 0 ? ( +
    + {visibleUploadFiles.map((file) => ( + previewFile(file)} + /> + ))} +
    - ))} -
    + ) : null} + {index.uploads.error ? ( + void index.loadMoreUploads()} /> -
    - ) : null} - {index.uploads.error ? ( - void index.loadMoreUploads()} + ) : null} + + effectiveFilterId === 'all' + ? revealNextAllPage( + 'uploads', + allUploadVisibleItemLimit, + index.uploads, + index.loadMoreUploads + ) + : void index.loadMoreUploads() + } /> - ) : null} - - effectiveFilterId === 'all' - ? revealNextAllPage( - 'uploads', - allUploadVisibleItemLimit, - index.uploads, - index.loadMoreUploads - ) - : void index.loadMoreUploads() + + ) : null} +
    + ) : null} + + {effectiveFilterId === 'all' && index.groups.error ? ( + void index.loadMoreGroups()} + /> + ) : null} + + {visibleArtifactGroups.length > 0 ? ( +
    + {effectiveFilterId === 'all' ? ( +
    + Generated files +
    + ) : null} + {visibleArtifactGroups.map((group) => ( + { + const sectionId = `session:${group.sessionId}` + const visibleItemLimit = allVisibleItemLimits[sectionId] ?? FILE_PAGE_SIZE + revealNextAllPage( + sectionId, + visibleItemLimit, + index.artifactsBySession[group.sessionId], + () => index.loadMoreArtifacts(group.sessionId) + ) + }} + previewById={currentFilePreviewById} + onPreview={previewFile} /> - - ) : null} -
    - ) : null} - - {effectiveFilterId === 'all' && index.groups.error ? ( - void index.loadMoreGroups()} /> - ) : null} - - {visibleArtifactGroups.length > 0 ? ( -
    - {effectiveFilterId === 'all' ? ( -
    - Generated files -
    - ) : null} - {visibleArtifactGroups.map((group) => ( - { - const sectionId = `session:${group.sessionId}` - const visibleItemLimit = allVisibleItemLimits[sectionId] ?? FILE_PAGE_SIZE - revealNextAllPage( - sectionId, - visibleItemLimit, - index.artifactsBySession[group.sessionId], - () => index.loadMoreArtifacts(group.sessionId) - ) - }} - previewById={currentFilePreviewById} - onPreview={previewFile} - /> - ))} -
    - {!supportsIntersectionObserver && - effectiveFilterId === 'all' && - index.groups.nextCursor && - !index.groups.isLoading && - !index.groups.error ? ( -
    - -
    - ) : null} -
    - ) : null} -
    + ))} +
    + {!supportsIntersectionObserver && + effectiveFilterId === 'all' && + index.groups.nextCursor && + !index.groups.isLoading && + !index.groups.error ? ( +
    + +
    + ) : null} +
    + ) : null} + + )} setDialogItem(undefined)} /> ({ vi.mock('../ProjectFilesView', () => ({ ProjectFilesView: (): React.JSX.Element =>
    files
    })) -vi.mock('../LocalFileBrowser', () => ({ - LocalFileBrowser: (): React.JSX.Element =>
    local
    -})) vi.mock('../SessionReviewerPanel', () => ({ SessionReviewerPanel: ({ review, @@ -65,12 +62,6 @@ describe('PreviewToolContent', () => { expect(render(createItem({ toolKind: 'files' }))).toContain('data-testid="project-files"') }) - it('routes the "this computer" tool to the local file browser', () => { - expect(render(createItem({ toolKind: 'local-browser' }))).toContain( - 'data-testid="local-browser"' - ) - }) - it('shows the reviewer empty state when the requested session has no reviews', () => { const html = render(createItem({ toolKind: 'reviewer', reviewerSessionId: 'review-session' })) diff --git a/src/renderer/src/pages/workspace/previews/PreviewToolContent.tsx b/src/renderer/src/pages/workspace/previews/PreviewToolContent.tsx index a541de510..20d94b28a 100644 --- a/src/renderer/src/pages/workspace/previews/PreviewToolContent.tsx +++ b/src/renderer/src/pages/workspace/previews/PreviewToolContent.tsx @@ -4,7 +4,6 @@ import type { PreviewToolItem } from '@/stores/preview-workbench-store' import { NotebookPreview } from '../NotebookPreview' import type { NotebookPreviewItem } from '../NotebookPreview' -import { LocalFileBrowser } from '../LocalFileBrowser' import { ProjectFilesView } from '../ProjectFilesView' import { SessionReviewerPanel } from '../SessionReviewerPanel' @@ -43,8 +42,6 @@ export const PreviewToolContent = ({ return } - if (item.toolKind === 'local-browser') return - if (item.toolKind === 'reviewer') return if (!isNotebookPreviewItem(item)) return null diff --git a/src/renderer/src/stores/preview-workbench-store.ts b/src/renderer/src/stores/preview-workbench-store.ts index b152ac7f4..d80a44843 100644 --- a/src/renderer/src/stores/preview-workbench-store.ts +++ b/src/renderer/src/stores/preview-workbench-store.ts @@ -25,7 +25,6 @@ export type PreviewFileFormat = // an absolute filesystem path read via window.api.localFs. export type PreviewFileSource = 'artifact' | 'upload' | 'local' export const PROJECT_FILES_PREVIEW_ID = 'tool:project:files' -export const LOCAL_BROWSER_PREVIEW_ID = 'tool:local:browser' type PreviewItemBase = { id: string @@ -47,7 +46,7 @@ export type PreviewFileItem = PreviewItemBase & { // Tool previews share the workbench chrome with files, but keep their own render path. export type PreviewToolItem = PreviewItemBase & { type: 'tool' - toolKind?: 'notebook' | 'files' | 'reviewer' | 'local-browser' + toolKind?: 'notebook' | 'files' | 'reviewer' notebook?: NotebookSessionReference // Reviewer-specific: which session's reviews to show, which review to select, and the active // finding to scroll to. @@ -164,16 +163,6 @@ const createProjectFilesPreviewItem = (): PreviewToolItem => ({ title: 'Files' }) -// Builds the stable "This computer" preview tab. The title carries the device name so the tab reads -// like the machine it browses; it falls back to "This computer" before roots resolve. -const createLocalBrowserPreviewItem = (machineName?: string): PreviewToolItem => ({ - id: LOCAL_BROWSER_PREVIEW_ID, - sessionId: '__local_browser__', - type: 'tool', - toolKind: 'local-browser', - title: machineName?.trim() || 'This computer' -}) - // Input for opening the Session reviewer panel; findingId/locator determine scroll position. export type SessionReviewerPreviewInput = { sessionId: string @@ -395,6 +384,5 @@ export const usePreviewWorkbenchStore = create((set, get) export { createNotebookPreviewItem, createProjectFilesPreviewItem, - createLocalBrowserPreviewItem, createSessionReviewerPreviewItem } From eaf2218a8901b5cea04d96b14c84f3d3b081d2e1 Mon Sep 17 00:00:00 2001 From: Roxi Date: Wed, 29 Jul 2026 10:27:16 +0800 Subject: [PATCH 05/14] polish(files): single parent arrow, lighter address bar, hover hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local browser toolbar had two arrows side by side — back and up — which read as a duplicate of the same action. Keep one arrow that goes to the parent folder and drop the history stack it was the only consumer of, so navigation is a single axis (parent arrow, address bar, Go-to, entry click). Also: - the address bar sat on bg-muted, which is darker than the bg-200 used for row hover; move it to bg-200 so the field reads as a lighter inset while keeping its border against the white container - every toolbar control is icon-only or icon + a two-word label, so add delayed (200ms) tooltips saying what each one does, including one on the pinned-folder ✕ clarifying that it unpins rather than deletes - the preview header's "…" button gains the same treatment, and Reveal's tooltip now describes the action instead of repeating its aria-label - title-case the "This computer" source label to match "Artifacts" Co-Authored-By: Claude Opus 5 --- .../src/pages/workspace/LocalFileBrowser.tsx | 241 +++++++++--------- .../workspace/LocalFileHeaderActions.tsx | 33 ++- .../src/pages/workspace/ProjectFilesView.tsx | 4 +- 3 files changed, 148 insertions(+), 130 deletions(-) diff --git a/src/renderer/src/pages/workspace/LocalFileBrowser.tsx b/src/renderer/src/pages/workspace/LocalFileBrowser.tsx index 818dcb1a3..0646e9146 100644 --- a/src/renderer/src/pages/workspace/LocalFileBrowser.tsx +++ b/src/renderer/src/pages/workspace/LocalFileBrowser.tsx @@ -1,14 +1,14 @@ // 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 (back/up/refresh, editable -// address bar, Go-to dropdown with a fixed Home + pin/unpin bookmarks) but: +// 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, - ArrowUp, Bookmark, ChevronDown, Folder, @@ -28,6 +28,7 @@ import { validateLocalPath } from '../../../../shared/local-fs' import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { usePreviewWorkbenchStore } from '@/stores/preview-workbench-store' @@ -71,6 +72,22 @@ type BrowserState = | { kind: 'ok'; entries: LocalDirEntry[]; resolvedPath: string; truncated: boolean } | { kind: 'error'; detail: string } +// 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} + +) + // Go-to dropdown: Home is fixed at the top (never removable); pinned folders follow with Unpin (✕). const GoToMenu = ({ home, @@ -129,14 +146,17 @@ const GoToMenu = ({ {path.split('/').pop() || path} - + {/* ✕ can read as "delete this folder", so the hint spells out that it only unpins. */} + + + ))} @@ -218,14 +238,10 @@ export const LocalFileBrowser = ({ const [roots, setRoots] = useState(null) const [cwd, setCwd] = useState('') const [state, setState] = useState({ kind: 'loading' }) - const [history, setHistory] = useState([]) const [addressInput, setAddressInput] = useState('') const [bookmarks, setBookmarks] = useState([]) const [gotoOpen, setGotoOpen] = useState(false) const upsertAndActivateItem = usePreviewWorkbenchStore((s) => s.upsertAndActivateItem) - // Mirrors cwd so navigate() can read the previous location without depending on it (keeps the - // callback identity stable) and without side effects inside a state updater (StrictMode-safe). - const cwdRef = useRef('') // Latest count reporter, read through a ref so navigate() stays identity-stable even when the // parent passes a fresh closure. const onEntryCountChangeRef = useRef(onEntryCountChange) @@ -241,16 +257,12 @@ export const LocalFileBrowser = ({ [] ) - // Loads one directory; pushes the previous cwd onto history unless replacing (back/refresh). - const navigate = useCallback(async (target: string, pushHistory = true): Promise => { + // 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) - const previous = cwdRef.current - if (pushHistory && previous && previous !== listing.resolvedPath) { - setHistory((h) => [...h, previous]) - } - cwdRef.current = listing.resolvedPath setState({ kind: 'ok', entries: listing.entries, @@ -275,9 +287,7 @@ export const LocalFileBrowser = ({ ]) setRoots(fetchedRoots) setBookmarks(fetchedBookmarks) - setHistory([]) - cwdRef.current = '' - await navigate(fetchedRoots.home, false) + await navigate(fetchedRoots.home) })() }, [navigate]) @@ -285,13 +295,6 @@ export const LocalFileBrowser = ({ const currentPath = listing?.resolvedPath ?? cwd const isAtRoot = currentPath === '/' - const handleBack = (): void => { - const prev = history[history.length - 1] - if (!prev) return - setHistory((h) => h.slice(0, -1)) - void navigate(prev, false) - } - // 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. @@ -359,93 +362,99 @@ export const LocalFileBrowser = ({ aria-label="Local file browser" > {/* 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 */} -
    - - {gotoOpen ? ( - { - setGotoOpen(false) - void navigate(path) - }} - onPinCurrent={() => { - setGotoOpen(false) - void handleToggleBookmark() - }} - onRemoveBookmark={(path) => void handleRemoveBookmark(path)} - /> - ) : null} -
    + {/* Go-to dropdown: fixed Home + pinned bookmarks */} +
    + + + + {gotoOpen ? ( + { + setGotoOpen(false) + void navigate(path) + }} + onPinCurrent={() => { + setGotoOpen(false) + void handleToggleBookmark() + }} + onRemoveBookmark={(path) => void handleRemoveBookmark(path)} + /> + ) : null} +
    - {/* Always-editable address bar */} -
    - setAddressInput(e.target.value)} - onBlur={handleAddressSubmit} - spellCheck={false} - className="w-full rounded-md border border-border bg-muted px-2 py-1 font-mono text-xs outline-none focus:ring-2 focus:ring-ring/50" - aria-label="Directory path" - /> -
    + {/* Always-editable address bar */} +
    + setAddressInput(e.target.value)} + onBlur={handleAddressSubmit} + spellCheck={false} + className="w-full rounded-md border border-border bg-bg-200 px-2 py-1 font-mono text-xs outline-none 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 index 7eedd0bad..ddff20468 100644 --- a/src/renderer/src/pages/workspace/LocalFileHeaderActions.tsx +++ b/src/renderer/src/pages/workspace/LocalFileHeaderActions.tsx @@ -77,21 +77,30 @@ export const LocalFileHeaderActions = ({