diff --git a/.changeset/web-workspace-path-entry.md b/.changeset/web-workspace-path-entry.md new file mode 100644 index 0000000000..df8a5ade34 --- /dev/null +++ b/.changeset/web-workspace-path-entry.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Add workspaces by typing an absolute path directly in the workspace picker's search box, with live validation and completion suggestions. diff --git a/apps/kimi-web/src/components/dialogs/AddWorkspaceDialog.vue b/apps/kimi-web/src/components/dialogs/AddWorkspaceDialog.vue index 945f0c360b..3144b59827 100644 --- a/apps/kimi-web/src/components/dialogs/AddWorkspaceDialog.vue +++ b/apps/kimi-web/src/components/dialogs/AddWorkspaceDialog.vue @@ -1,18 +1,26 @@ - - - - - + + + + + + + + @@ -409,6 +555,10 @@ onUnmounted(() => { .filter-input::placeholder { color: var(--color-text-muted); } .search-rel { color: var(--color-text); } +/* Path-mode error: tint the shared box's border + icon. */ +.filterbar.has-error { border-bottom-color: var(--color-danger); } +.filterbar.has-error .filter-icon { color: var(--color-danger); } + /* Folder list */ .folder-list { height: 300px; @@ -421,6 +571,12 @@ onUnmounted(() => { color: var(--color-text-muted); font-size: var(--text-sm); } +.fl-note { + padding: var(--space-2) var(--space-4); + font-size: var(--text-sm); + color: var(--color-text-muted); +} +.fl-error { color: var(--color-danger); } .folder-row { display: flex; align-items: center; @@ -449,18 +605,13 @@ onUnmounted(() => { } .git-branch { color: var(--color-text-muted); } -/* Paste-path escape hatch */ -.paste-section { - padding: var(--space-3) var(--space-5); - border-top: 1px solid var(--color-line); -} -.paste-section.paste-only { border-top: none; } -.paste-row { - display: flex; - align-items: center; - gap: var(--space-2); +/* Degraded mode (daemon can't browse): compact hint under the input box. */ +.degraded-hint { + padding: var(--space-6) var(--space-5); + text-align: center; + color: var(--color-text-muted); + font-size: var(--text-sm); } -.paste-input-wrap { flex: 1; min-width: 0; } /* Actions */ .add-error { diff --git a/apps/kimi-web/src/i18n/locales/en/workspace.ts b/apps/kimi-web/src/i18n/locales/en/workspace.ts index c9dbf16f3b..bb6556eb53 100644 --- a/apps/kimi-web/src/i18n/locales/en/workspace.ts +++ b/apps/kimi-web/src/i18n/locales/en/workspace.ts @@ -22,25 +22,28 @@ export default { newInGroup: 'New session in this workspace', // Add-workspace dialog addTitle: 'Add workspace', - pathLabel: 'Path', - pathPlaceholder: '/absolute/path/to/project', recentLabel: 'Recent folders', - add: 'Add', cancel: 'Cancel', - addHint: 'Paste an absolute folder path, or pick a recent one.', addFailed: "Couldn't open this folder. Check the path and try again.", // Folder browser openThisFolder: 'Open this folder', up: 'Up', browsing: 'Browsing…', filterPlaceholder: 'Filter subfolders…', - searchPlaceholder: 'Fuzzy-search under this folder…', + searchPlaceholder: 'Fuzzy-search subfolders, or paste an absolute path…', searching: 'Searching…', - pasteToggle: 'Enter an absolute path', noFilterMatch: 'No subfolders match “{q}”', noSubfolders: 'No subfolders here', gitTag: 'git', browseHint: 'Click a folder to enter it, then "Open this folder" to add it as a workspace.', + // Path entry (absolute path typed into the same box) + checkingPath: 'Checking path…', + pathPickHint: 'Path not found — did you mean:', + noPathMatch: 'Path not found — no matching folders under {parent}', + badParent: 'Parent directory does not exist: {parent}', + pathFollowHint: 'Folder located — press Enter or "Open this folder" to add it.', + degradedPlaceholder: 'Type an absolute path, press Enter to add…', + degradedHint: 'File browsing is unavailable — type an absolute path and press Enter to add.', // Attention marker attentionTitle: '{count} item needs your attention | {count} items need your attention', // Per-session pending tags (sidebar) diff --git a/apps/kimi-web/src/i18n/locales/zh/workspace.ts b/apps/kimi-web/src/i18n/locales/zh/workspace.ts index aae5fee9ee..23e6e38ada 100644 --- a/apps/kimi-web/src/i18n/locales/zh/workspace.ts +++ b/apps/kimi-web/src/i18n/locales/zh/workspace.ts @@ -22,25 +22,28 @@ export default { newInGroup: '在此工作区新建会话', // Add-workspace dialog addTitle: '添加工作区', - pathLabel: '路径', - pathPlaceholder: '/项目的绝对路径', recentLabel: '最近的文件夹', - add: '添加', cancel: '取消', - addHint: '粘贴一个绝对路径,或从最近用过的文件夹中选择。', addFailed: '无法打开此文件夹,请检查路径后重试。', // Folder browser openThisFolder: '打开此文件夹', up: '上一级', browsing: '加载中…', filterPlaceholder: '过滤子文件夹…', - searchPlaceholder: '在此目录下模糊搜索…', + searchPlaceholder: '模糊搜索子文件夹,或粘贴绝对路径…', searching: '搜索中…', - pasteToggle: '直接输入绝对路径', noFilterMatch: '没有匹配「{q}」的子文件夹', noSubfolders: '此处没有子文件夹', gitTag: 'git', browseHint: '点击文件夹进入,再点"打开此文件夹"将其添加为工作区。', + // Path entry (absolute path typed into the same box) + checkingPath: '检查路径…', + pathPickHint: '此路径不存在,你是不是想找:', + noPathMatch: '此路径不存在,「{parent}」下没有匹配的文件夹', + badParent: '上级目录不存在:{parent}', + pathFollowHint: '已定位到目标文件夹,按回车或点击"打开此文件夹"完成添加。', + degradedPlaceholder: '输入绝对路径,回车添加…', + degradedHint: '守护进程无法浏览文件系统,请直接输入绝对路径后回车添加。', // Attention marker attentionTitle: '{count} 项待处理', // Per-session pending tags (sidebar) diff --git a/apps/kimi-web/src/lib/workspacePathInput.ts b/apps/kimi-web/src/lib/workspacePathInput.ts new file mode 100644 index 0000000000..050c3d5b95 --- /dev/null +++ b/apps/kimi-web/src/lib/workspacePathInput.ts @@ -0,0 +1,93 @@ +const PATH_LIKE = /^(?:\/|~(?:\/|$)|[A-Za-z]:[\\/]|\\\\)/; +const WINDOWS_DRIVE = /^[A-Za-z]:[\\/]/; +const FORWARD_UNC = /^\/\/(?!\/)/; + +export type WorkspacePathSeparator = '/' | '\\'; + +export interface ParsedWorkspacePathInput { + target: string; + parent: string; + base: string; + separator: WorkspacePathSeparator; +} + +export function isWorkspacePathInput(raw: string): boolean { + return PATH_LIKE.test(raw.trim()); +} + +function expandTilde(raw: string, homePath: string): string { + if (raw === '~') return homePath || raw; + if (raw.startsWith('~/')) return (homePath || '~') + raw.slice(1); + return raw; +} + +function normalizeForwardSlashes(path: string): string { + if (FORWARD_UNC.test(path)) { + return `//${path.slice(2).replaceAll(/\/{2,}/g, '/')}`; + } + return path.replaceAll(/\/{2,}/g, '/'); +} + +function isWindowsPath(path: string): boolean { + return WINDOWS_DRIVE.test(path) || path.startsWith('\\\\') || path.startsWith('//'); +} + +function rootLength(path: string): number { + if (WINDOWS_DRIVE.test(path)) return 3; + if (path.startsWith('\\\\') || path.startsWith('//')) return 2; + if (path.startsWith('/')) return 1; + return 0; +} + +export function parseWorkspacePathInput( + raw: string, + homePath: string, +): ParsedWorkspacePathInput { + let target = normalizeForwardSlashes(expandTilde(raw.trim(), homePath)); + const windowsPath = isWindowsPath(target); + const isRoot = + target === '/' || + target === '//' || + target === '\\\\' || + /^[A-Za-z]:[\\/]$/.test(target); + + // A trailing backslash is a separator only for Windows-shaped paths. On + // POSIX it may be part of the directory name and must stay untouched. + const hasTrailingSeparator = windowsPath ? /[\\/]$/.test(target) : target.endsWith('/'); + if (!isRoot && hasTrailingSeparator) target = target.slice(0, -1); + + const slash = target.lastIndexOf('/'); + const backslash = windowsPath ? target.lastIndexOf('\\') : -1; + const lastSeparator = Math.max(slash, backslash); + const separator: WorkspacePathSeparator = backslash > slash ? '\\' : '/'; + const root = rootLength(target); + const parent = + lastSeparator < root + ? target.slice(0, root) || '/' + : target.slice(0, lastSeparator) || '/'; + + return { + target, + parent, + base: target.slice(lastSeparator + 1), + separator, + }; +} + +export function joinWorkspacePathCandidate( + parent: string, + name: string, + separator: WorkspacePathSeparator, +): string { + return `${parent}${parent.endsWith(separator) ? '' : separator}${name}`; +} + +export function currentValidatedWorkspacePath( + raw: string, + homePath: string, + validatedPath: string | null, +): string | null { + if (validatedPath === null) return null; + const { target } = parseWorkspacePathInput(raw, homePath); + return target === validatedPath ? validatedPath : null; +} diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts index 3afb035ca2..ab2d517e52 100644 --- a/apps/kimi-web/test/lib-logic.test.ts +++ b/apps/kimi-web/test/lib-logic.test.ts @@ -11,6 +11,12 @@ import { createCoalescedAsyncRunner } from '../src/lib/snapshotSync'; import { mergeSnapshotMessages } from '../src/lib/snapshotMessages'; import { normalizeToolName, toolSummary } from '../src/lib/toolMeta'; import { collapsePrompt, humanizeCron } from '../src/lib/cronHumanize'; +import { + currentValidatedWorkspacePath, + isWorkspacePathInput, + joinWorkspacePathCandidate, + parseWorkspacePathInput, +} from '../src/lib/workspacePathInput'; import { coerceThinkingForModel, commitLevel, @@ -26,6 +32,66 @@ import EditTool from '../src/components/chat/tool-calls/EditTool.vue'; import GenericTool from '../src/components/chat/tool-calls/GenericTool.vue'; import type { ToolCall } from '../src/types'; +describe('workspace path input', () => { + it('recognizes the supported absolute path forms', () => { + expect(isWorkspacePathInput('/tmp/project')).toBe(true); + expect(isWorkspacePathInput('~/project')).toBe(true); + expect(isWorkspacePathInput('C:\\project')).toBe(true); + expect(isWorkspacePathInput('C:/project')).toBe(true); + expect(isWorkspacePathInput('\\\\server\\share')).toBe(true); + expect(isWorkspacePathInput('project')).toBe(false); + }); + + it('normalizes separators without changing UNC roots or POSIX backslashes', () => { + expect(parseWorkspacePathInput('/tmp//project/', '').target).toBe('/tmp/project'); + expect(parseWorkspacePathInput('//server//share/project/', '').target).toBe('//server/share/project'); + expect(parseWorkspacePathInput('///tmp//project/', '').target).toBe('/tmp/project'); + expect(parseWorkspacePathInput('/tmp/project\\', '').target).toBe('/tmp/project\\'); + expect(parseWorkspacePathInput('~/project', '/home/alice').target).toBe('/home/alice/project'); + }); + + it('preserves Windows root separators in parent paths', () => { + expect(parseWorkspacePathInput('C:\\Use', '')).toMatchObject({ + parent: 'C:\\', + base: 'Use', + separator: '\\', + }); + expect(parseWorkspacePathInput('C:/Use', '')).toMatchObject({ + parent: 'C:/', + base: 'Use', + separator: '/', + }); + expect(parseWorkspacePathInput('\\\\server\\share\\pro', '')).toMatchObject({ + parent: '\\\\server\\share', + base: 'pro', + separator: '\\', + }); + expect(parseWorkspacePathInput('//server/share/pro', '')).toMatchObject({ + parent: '//server/share', + base: 'pro', + separator: '/', + }); + }); + + it('treats backslashes as literal characters in POSIX paths', () => { + expect(parseWorkspacePathInput('/tmp/foo\\bar', '')).toMatchObject({ + parent: '/tmp', + base: 'foo\\bar', + separator: '/', + }); + }); + + it('builds completion paths from the lexical parent', () => { + const parsed = parseWorkspacePathInput('/tmp/link/proje', ''); + expect(joinWorkspacePathCandidate(parsed.parent, 'project', parsed.separator)).toBe('/tmp/link/project'); + }); + + it('only returns a validated path while it still matches the current input', () => { + expect(currentValidatedWorkspacePath('/var', '', '/tmp')).toBeNull(); + expect(currentValidatedWorkspacePath('/tmp/', '', '/tmp')).toBe('/tmp'); + }); +}); + describe('parseDiff', () => { it('parses multiple files and keeps hunk line numbers', () => { const diff = [