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'