diff --git a/CHANGELOG.md b/CHANGELOG.md index e652691..39431db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,29 @@ This project follows [Keep a Changelog](https://keepachangelog.com/). The `## Unreleased` section at the top tracks changes that have landed on the development branch but are not yet cut into a release. +## Unreleased — feat-workspace-lhl + +> 工作区选择器重构(feature branch,未合并) + +### Added + +- **后端原生目录选择器**(`server/lib/workspace.js`): + - `pickDirectoryNative(signal)` — 后端 spawn 原生 OS 对话框,无浏览器授权弹窗 + - Linux: `zenity --file-selection --directory` → `kdialog --getexistingdirectory` fallback + - macOS: `osascript -e "choose folder"` + - Windows: PowerShell `System.Windows.Forms.FolderBrowserDialog` + - `getRecentWorkspaces(search, limit)` — 从 sessions DB 模糊搜索最近工作区(上限 20 条) +- **新 API 端点**: + - `GET /api/workspace/recent?search=&limit=` — 返回最近工作区列表 + tmpDir + - `POST /api/workspace/pick` — 触发原生目录选择器 + +### Changed + +- **前端工作区弹层**(`public/index.html` + `public/app/events.js`): + - 推翻 `webkitdirectory` input(触发浏览器上传 UI)和 `showDirectoryPicker`(Chrome 首次授权弹窗) + - 改为:搜索框(300ms debounce)→ recent 列表(后端 DB 模糊搜索)→「创建或打开新空间」触发原生 picker →「无需工作空间」 + - Chip 下拉(`ws-quick-picker`)改为使用 HTML 静态 DOM,而非动态创建 + ## v2.0.0 — 2026-09-20 (工业化重写,同步自 MiniMax-Code-Plugins PR #55 @ 7b4aae8) v1.x 单体 `server.js` 的工业化重写。本轮同步包含 PR #55 全量 26 提交, diff --git a/docs/API.md b/docs/API.md index eab7843..d5dbceb 100644 --- a/docs/API.md +++ b/docs/API.md @@ -270,6 +270,42 @@ When `path` is omitted: - Windows: `roots: ["C:", "D:", …]` - Linux: `children: [{name: "/", path: "/", isDir: true}]` +### `GET /api/workspace/recent?search=&limit=` + +Return recent workspaces from sessions DB (fuzzy search + count per dir). + +**Request** query: +- `search` (string, optional) — fuzzy match on directory path; defaults to `""` +- `limit` (int, optional) — max items to return; server-side cap 20; defaults to `5` + +**Response 200** +```json +{ + "ok": true, + "items": [ + { "dir": "/home/user/project", "count": 3 } + ], + "tmpDir": "/tmp" +} +``` + +### `POST /api/workspace/pick` + +Spawn a native OS directory picker dialog (no browser permission prompt). + +**Implementation (v2 — feat-workspace-lhl):** +- Linux: `zenity --file-selection --directory` → `kdialog --getexistingdirectory` fallback +- macOS: `osascript -e "choose folder"` +- Windows: PowerShell `System.Windows.Forms.FolderBrowserDialog` + +**Request** `{}` (empty body) + +**Response 200** +```json +{ "ok": true, "dir": "/path/to/chosen/dir" } +``` +User cancelled → `{"ok": true, "dir": ""}` + --- ## Settings diff --git a/electron-main.cjs b/electron-main.cjs new file mode 100644 index 0000000..91efda3 --- /dev/null +++ b/electron-main.cjs @@ -0,0 +1,232 @@ +// electron-main.cjs — Electron 主进程(CommonJS) +// 职责: +// 1. 启动 Node.js server.js 子进程(真正的 webui 后端,端口 8081) +// 2. 创建 BrowserWindow,加载 Electron HTTP 服务(端口 8080) +// 3. Electron HTTP 服务代理 /api/* → Node.js 后端(8081) +// 4. 通过 IPC 处理 dialog.showOpenDirectory 调用 +// +// 用户:npm run electron + +const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron') +const http = require('node:http') +const path = require('node:path') +const { URL } = require('node:url') +const { spawn } = require('node:child_process') + +// ---- 配置 ---- +const HTTP_PORT = 8080 // Electron HTTP 服务端口(BrowserWindow 加载这个) +const BACKEND_PORT = 8081 // Node.js 后端端口(server.js 子进程) +const BACKEND_SCRIPT = path.join(__dirname, 'server.js') +const DIST_DIR = path.join(__dirname, 'public') + +// ---- MIME 类型 ---- +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript', + '.mjs': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.svg': 'image/svg+xml', + '.woff2':'font/woff2', + '.woff': 'font/woff', + '.ttf': 'font/ttf', +} + +// ---- 启动 Node.js 后端子进程 ---- +let backendProcess = null + +function startBackend() { + console.log('[electron] 启动 Node.js 后端...') + // 明确使用系统 node(不被 npm_config_electron_path 污染) + const nodePath = process.env.NODE_PATH || '/usr/bin/node' + backendProcess = spawn(nodePath, [BACKEND_SCRIPT], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, PORT: String(BACKEND_PORT) }, + detached: true, + }) + backendProcess.stdout.on('data', (d) => process.stdout.write('[backend] ' + d)) + backendProcess.stderr.on('data', (d) => process.stderr.write('[backend] ' + d)) + backendProcess.on('error', (e) => console.error('[electron] 后端启动失败:', e.message)) + backendProcess.on('exit', (code) => { + console.log(`[electron] 后端退出,code=${code}`) + app.quit() + }) +} + +// ---- Electron HTTP 服务:静态文件 + API 代理 ---- +function createServer() { + return http.createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${HTTP_PORT}`) + const pathname = url.pathname + + // ---- API 请求代理到 Node.js 后端 ---- + if (pathname.startsWith('/api/')) { + // 读取 body(用于 POST) + const bodyChunks = [] + for await (const chunk of req) { bodyChunks.push(chunk) } + const body = Buffer.concat(bodyChunks) + + // 复制 headers,删除 hop-by-hop headers(用 delete 不要用 undefined) + const proxyHeaders = { ...req.headers } + delete proxyHeaders['connection'] + delete proxyHeaders['transfer-encoding'] + delete proxyHeaders['content-length'] + if (body.length > 0) proxyHeaders['content-length'] = body.length + + const options = { + hostname: 'localhost', + port: BACKEND_PORT, + path: pathname + (url.search || ''), + method: req.method, + headers: proxyHeaders, + } + + return new Promise((resolve) => { + const proxyReq = http.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers) + proxyRes.pipe(res) + resolve() + }) + proxyReq.on('error', (e) => { + console.error('[electron] 代理失败:', e.message) + res.writeHead(502) + res.end('Backend unavailable') + resolve() + }) + if (body.length > 0) proxyReq.write(body) + proxyReq.end() + }) + } + + // ---- 静态文件服务 ---- + try { + const fs = require('node:fs') + let filePath = path.join(DIST_DIR, pathname === '/' ? 'index.html' : pathname) + + // 安全:禁止访问 public 目录以外的文件 + if (!filePath.startsWith(DIST_DIR + path.sep)) { + res.writeHead(403) + res.end('Forbidden') + return + } + + if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) { + filePath = path.join(DIST_DIR, 'index.html') // SPA fallback + } + + const ext = path.extname(filePath) + const content = fs.readFileSync(filePath) + res.writeHead(200, { + 'Content-Type': MIME[ext] || 'application/octet-stream', + 'Cache-Control': 'no-cache', + }) + res.end(content) + } catch (err) { + console.error('[electron-server]', err) + res.writeHead(500) + res.end('Internal Server Error') + } + }) +} + +// ---- IPC 处理器 ---- + +// dialog.showOpenDirectory — 打开 OS 原生目录选择器 +ipcMain.handle('electron:dialog:showOpenDirectory', async (event, options = {}) => { + if (!BrowserWindow.getAllWindows().length) return { canceled: true, filePaths: [] } + const win = BrowserWindow.getAllWindows()[0] + const result = await dialog.showOpenDialog(win, { + properties: ['openDirectory'], + title: options.title || '选择工作目录', + defaultPath: options.defaultPath || undefined, + }) + return result +}) + +// dialog.showMessageBox +ipcMain.handle('electron:dialog:showMessageBox', async (event, options = {}) => { + if (!BrowserWindow.getAllWindows().length) return { response: -1 } + const win = BrowserWindow.getAllWindows()[0] + const result = await dialog.showMessageBox(win, options) + return result +}) + +// shell.openPath +ipcMain.handle('electron:shell:openPath', async (event, filePath) => { + return shell.openPath(filePath) +}) + +// ---- App 生命周期 ---- +app.whenReady().then(() => { + startBackend() + + // 等待后端启动(轮询端口) + const waitForBackend = () => { + const sock = require('node:net').connect(BACKEND_PORT, 'localhost', () => { + sock.destroy() + const server = createServer() + server.listen(HTTP_PORT, '127.0.0.1', () => { + console.log(`[electron] HTTP server listening on http://127.0.0.1:${HTTP_PORT}`) + console.log(`[electron] 后端 API 代理到 http://127.0.0.1:${BACKEND_PORT}`) + createWindow() + }) + }) + sock.on('error', () => { + setTimeout(waitForBackend, 200) + }) + } + waitForBackend() +}) + +// macOS:点击 dock 图标时如果没有窗口就新建 +app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow() +}) + +app.on('window-all-closed', () => { + if (backendProcess) backendProcess.kill() + if (process.platform !== 'darwin') app.quit() +}) + +app.on('before-quit', () => { + if (backendProcess) backendProcess.kill() +}) + +process.on('SIGINT', () => app.quit()) +process.on('SIGTERM', () => app.quit()) + +// ---- 创建窗口 ---- +let mainWindow = null + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1280, + height: 800, + minWidth: 800, + minHeight: 600, + title: 'Mcode WebUI', + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: false, + preload: path.join(__dirname, 'electron-preload.cjs'), + }, + }) + + // 调试:验证 preload 注入(开发时取消注释) + // mainWindow.webContents.on('did-finish-load', () => { + // mainWindow.webContents.executeJavaScript(` + // console.log('window.dialog:', typeof window.dialog !== 'undefined' ? Object.keys(window.dialog) : 'NOT FOUND') + // `).catch(e => console.error('[electron] executeJS failed:', e.message)) + // }) + + mainWindow.loadURL(`http://127.0.0.1:${HTTP_PORT}/`) + + if (process.env.ELECTRON_DEV) { + mainWindow.webContents.openDevTools() + } + + mainWindow.on('closed', () => { mainWindow = null }) +} diff --git a/electron-preload.cjs b/electron-preload.cjs new file mode 100644 index 0000000..7567fcf --- /dev/null +++ b/electron-preload.cjs @@ -0,0 +1,21 @@ +// electron-preload.cjs — Electron preload 脚本(CommonJS) +// 通过 contextBridge 安全暴露 electron API 给渲染进程(前端 JS) +// +// 暴露的 API: +// window.dialog.showOpenDirectory(options) → Promise<{ canceled, filePaths }> +// window.dialog.showMessageBox(options) → Promise<{ response }> +// window.shell.openPath(filePath) → Promise + +const { contextBridge, ipcRenderer } = require('electron') + +contextBridge.exposeInMainWorld('dialog', { + showOpenDirectory: (options) => + ipcRenderer.invoke('electron:dialog:showOpenDirectory', options), + + showMessageBox: (options) => + ipcRenderer.invoke('electron:dialog:showMessageBox', options), +}) + +contextBridge.exposeInMainWorld('shell', { + openPath: (filePath) => ipcRenderer.invoke('electron:shell:openPath', filePath), +}) diff --git a/package.json b/package.json index 4fb55d0..66e4a97 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "start": "node server.js", "dev": "node server.js", + "electron": "electron electron-main.cjs --no-sandbox --disable-gpu", "test": "node --experimental-test-module-mocks --test test/*.test.js checks/*.check.mjs test/integration/*.test.js test/matrix/*.test.js", "test:unit": "node --test test/*.test.js", "test:mocked": "node --experimental-test-module-mocks --test checks/*.check.mjs", @@ -21,7 +22,8 @@ "node": ">=22.19 <23 || >=24 <27" }, "devDependencies": { - "c8": "^12.0.0" + "c8": "^12.0.0", + "electron": "^36.0.0" }, "c8": { "include": [ diff --git a/public/app/events.js b/public/app/events.js index 18d5c52..81f166a 100644 --- a/public/app/events.js +++ b/public/app/events.js @@ -5,7 +5,9 @@ import { applyI18n, applyTheme, currentLang, setLang, t, toggleTheme } from './i18n.js' import { MODE_ICONS, __DBG, escapeHtml, formatNumber, formatResetTime, formatTimeUntil, nextFiveHourReset, nextWeeklyReset, parseMarkdown, showToast } from './util.js' +// v2.0 (feat-workspace-lhl): 零弹窗目录选择(showDirectoryPicker 优先,webkitdirectory 回退) import { refreshSessions, API_SUFFIX, CID, CID_QUERY, HEADERS, TOKEN, TOKEN_QUERY, autoRefreshTimer, clearAlerts, closeApiKeyModal, connect, es, getGeneralQuota, getPendingAuthRequests, leftOpen, markAllAlertsRead, openApiKeyModal, refreshUsage, renderUsage, renderUsagePopover, renderUsageValue, rightOpen, sessionSearchQuery, setLeftOpen, setRightOpen, setSearchQuery, setSidebarReady, setState, state, submitAuthDecision, toggleUsagePopover, tokenParam, urlParams } from './state.js' +import { pickDirectory } from './native-fs.js' import { ASK_ANSWERS_LS_KEY, ASK_DISMISSED_LS_KEY, ASK_MODAL_STATE, AUTH_MODAL_STATE, DISMISSED_QUESTIONS, askModalNextOrSend, askModalPqKey, askModalSkip, attachStructuredBlockHandlers, bindAskModal, buildAskUserPrompt, cancelConfirm, clearAskPresentedKeys, closeAskModal, collapsedWorkspaces, collectAskBlock, collectPlanBlock, deleteSession, hideRightForWelcome, loadAskDismissed, loadAskUserAnswers, onAskModalOptClick, onAskModalOtherInput, openAskModal, openPlanModal, parseChatLines, render, renderAlerts, renderAskBlock, renderAskModalContent, renderAskUserToolIfChanged, renderChat, renderContext, renderGoal, renderLanCardContent, renderMessage, renderPlanBlock, renderRight, renderSessions, renderTodo, renderUserFooter, resetAskDismissed, saveAskDismissed, saveAskUserAnswers, saveCollapsedWorkspaces, sendAskAnswer, setAskUserAnswer, submitAskModal, suppressAskModal, switchSession, wsShortName } from './render.js' export let slashOpen = false @@ -653,61 +655,26 @@ export function attachEvents() { } }) - // v0.5.al: workspace 切换 — chip-workspace click 弹 popover + // v2 (feat-workspace-lhl): 工作区弹层 — 搜索框 + recent 列表 + 原生 picker 按钮 + // 实现方案:dsh 同款后端 spawn 原生 OS 对话框(无浏览器授权弹窗) const wsPicker = document.getElementById('workspace-picker') - const wsInput = document.getElementById('workspace-picker-input') - const wsCurrent = document.getElementById('workspace-picker-current') const wsChip = document.getElementById('chip-workspace') - const wsRecentsEl = document.getElementById('workspace-picker-recents') - const wsRecentsList = document.getElementById('workspace-picker-recents-list') - const wsSyncCheckbox = document.getElementById('workspace-picker-sync') + const wsSearchInput = document.getElementById('ws-picker-search') + const wsRecentList = document.getElementById('ws-picker-recent-list') + const wsCreateBtn = document.getElementById('ws-picker-pick-btn') + const wsNoWsBtn = document.getElementById('ws-picker-nows-btn') + const emptyWsBtn = document.getElementById('chat-empty-workspace') - // localStorage 工具:保存/读取 recents - const WS_RECENTS_KEY = 'webui_workspace_recents_v1' const WS_LAST_KEY = 'webui_workspace_last_v1' - function loadWsRecents() { - try { return JSON.parse(localStorage.getItem(WS_RECENTS_KEY) || '[]') } catch { return [] } - } - function saveWsRecents(arr) { - try { localStorage.setItem(WS_RECENTS_KEY, JSON.stringify(arr.slice(0, 5))) } catch {} - } - function pushWsRecent(dir) { - if (!dir) return - const cur = loadWsRecents().filter(d => d !== dir) - cur.unshift(dir) - saveWsRecents(cur) - } - function renderWsRecents() { - const recents = loadWsRecents() - if (recents.length === 0) { - wsRecentsEl.hidden = true - return - } - wsRecentsEl.hidden = false - wsRecentsList.innerHTML = '' - for (const dir of recents) { - const btn = document.createElement('div') - btn.className = 'workspace-picker-recent' - btn.textContent = dir - btn.title = dir - btn.addEventListener('click', (e) => { - e.stopPropagation() - wsInput.value = dir - submitWorkspaceChange({ dir }) - }) - wsRecentsList.appendChild(btn) - } - } + const WS_SEARCH_DEBOUNCE_MS = 300 + const WS_RECENT_LIMIT = 20 - function positionWsPicker() { - // v0.5.bg: 用户反馈"显示不全" — 改成屏幕居中显示(fixed + 50% translate),不再跟 chip - if (!wsPicker) return - // 居中定位(fixed + transform) - wsPicker.style.top = '50%' - wsPicker.style.left = '50%' - wsPicker.style.transform = 'translate(-50%, -50%)' - } + // 缓存 + let wsRecentCache = [] // [{dir, count}] + let wsTmpDir = '' + let wsSearchTimer = null + // ---- 核心:切换工作区 ---- async function submitWorkspaceChange(payload) { try { const r = await fetch('/api/workspace' + API_SUFFIX, { @@ -718,12 +685,10 @@ export function attachEvents() { const data = await r.json() if (data.ok) { if (payload && payload.dir) { - pushWsRecent(data.workspace.dir) try { localStorage.setItem(WS_LAST_KEY, data.workspace.dir) } catch {} } wsPicker.hidden = true - // server 会 push state,render() 自动更新 chip + 输入区 - // 立即本地 fallback(不等 SSE) + hideWsQuickPicker() if (state) { state.workspace = data.workspace render() @@ -737,267 +702,244 @@ export function attachEvents() { } } - wsChip?.addEventListener('click', (e) => { - e.stopPropagation() - openWsPicker() - }) + // ---- 搜索 & recent 列表渲染 ---- + function wsRenderRecent(items) { + if (!wsRecentList) return + if (!items || items.length === 0) { + wsRecentList.innerHTML = '
暂无最近工作区
' + return + } + const cur = (state && state.workspace && state.workspace.dir) || '' + const folderIcon = `` + wsRecentList.innerHTML = items.map(item => { + const active = item.dir === cur ? ' active' : '' + const name = wsShortName(item.dir) || item.dir + // 路径截断至30字符,tooltip显示完整路径 + const fullPath = item.dir + const shortPath = fullPath.length > 30 ? '…' + fullPath.slice(-30) : fullPath + const count = item.count > 0 ? `${item.count}` : '' + return `
+ ${folderIcon} +
+ ${escapeHtml(name)} + ${escapeHtml(shortPath)} +
${count} +
` + }).join('') - // v0.5.ax: 欢迎页的工作区 chip 也触发同样的 popover - const emptyWsBtn = document.getElementById('chat-empty-workspace') - if (emptyWsBtn) { - emptyWsBtn.addEventListener('click', (e) => { - e.stopPropagation() - // v0.5.ax: 仅在 welcome 态(无消息)允许改工作区 - const hasMessages = (state?.chat?.length || 0) > 0 - if (hasMessages) { - showToast(t('workspace_locked_in_chat')) - return - } - openWsPicker() + // 点击即切换 + wsRecentList.querySelectorAll('.ws-picker-recent-item').forEach(el => { + el.addEventListener('click', () => { + const dir = el.dataset.dir + wsPicker.hidden = true + submitWorkspaceChange({ dir, syncTui: false }) + }) }) } + async function wsLoadRecent(search = '') { + try { + const url = '/api/workspace/recent' + API_SUFFIX + '&search=' + encodeURIComponent(search) + '&limit=' + WS_RECENT_LIMIT + const r = await fetch(url, { headers: HEADERS }) + const data = await r.json() + if (data && data.ok) { + wsRecentCache = data.items || [] + wsTmpDir = data.tmpDir || wsTmpDir + wsRenderRecent(wsRecentCache) + } + } catch (e) { + console.error('[ws] load recent failed', e) + if (wsRecentList) wsRecentList.innerHTML = '
加载失败
' + } + } + + function wsOnSearchInput() { + clearTimeout(wsSearchTimer) + wsSearchTimer = setTimeout(() => { + wsLoadRecent(wsSearchInput.value.trim()) + }, WS_SEARCH_DEBOUNCE_MS) + } + + // ---- 弹层打开 / 关闭 ---- function openWsPicker() { const wasHidden = wsPicker.hidden - // 关掉其他 popover document.querySelectorAll('.mode-popover, .settings-menu, .model-picker, .workspace-picker').forEach(el => { if (el !== wsPicker) el.hidden = true }) + hideWsQuickPicker() wsPicker.hidden = !wasHidden if (!wsPicker.hidden) { - // 显示当前工作区全路径 - const cur = (state && state.workspace && state.workspace.dir) || '' - wsCurrent.textContent = cur || t('workspace_unset') - wsInput.value = cur - renderWsRecents() - // 探测 TUI cwd,让 "跟随 TUI" 按钮显示真实路径(tooltip) - fetch('/api/workspace' + API_SUFFIX, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...HEADERS }, - body: JSON.stringify({ action: 'detect' }), - }).then(r => r.json()).then(data => { - if (data && data.tuiCwd) { - const tuiBtn = document.getElementById('workspace-picker-tui') - if (tuiBtn) tuiBtn.title = 'mcode TUI 当前在: ' + data.tuiCwd - } - }).catch(() => {}) + // 清搜索框,加载 recent + if (wsSearchInput) { wsSearchInput.value = ''; wsSearchInput.focus() } + wsLoadRecent('') setTimeout(() => { - positionWsPicker() - wsInput.focus() - wsInput.select() + wsPicker.style.top = '50%' + wsPicker.style.left = '50%' + wsPicker.style.transform = 'translate(-50%, -50%)' }, 0) } } - // 输入框回车 = 切换 - wsInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault() - const dir = wsInput.value.trim() - if (!dir) return - submitWorkspaceChange({ dir, syncTui: wsSyncCheckbox.checked }) - } else if (e.key === 'Escape') { - e.stopPropagation() - wsPicker.hidden = true - } + wsChip?.addEventListener('click', (e) => { + e.stopPropagation() + openWsPicker() }) - // v0.5.by: 3 按钮 (workspace-picker-confirm/tui/reset) HTML 已删 — Enter 键提交已覆盖 - // (见上方 wsInput.addEventListener('keydown') Enter 分支) - - // v0.5.am: 可视化目录树浏览(懒加载) - const browseToggle = document.getElementById('workspace-picker-browse-toggle') - const browsePanel = document.getElementById('workspace-picker-browse-panel') - const browseChevron = document.getElementById('workspace-picker-browse-chevron') - const breadcrumb = document.getElementById('workspace-picker-breadcrumb') - const treeEl = document.getElementById('workspace-picker-tree') - const treeLoading = document.getElementById('workspace-picker-tree-loading') - // 内存缓存:path -> {ok, children, roots} 避免重复请求 - const browseCache = new Map() - // 当前浏览的目录 - let browseCurrent = null // absolute path or null (= roots) - let browseOpen = false - - function fetchBrowse(path) { - const key = path || '__roots__' - if (browseCache.has(key)) return Promise.resolve(browseCache.get(key)) - const url = '/api/workspace/browse' + API_SUFFIX + (path ? '&path=' + encodeURIComponent(path) : '') - return fetch(url, { headers: HEADERS }) - .then(r => r.json()) - .then(data => { browseCache.set(key, data); return data }) - } + // ---- 搜索框事件 ---- + wsSearchInput?.addEventListener('input', wsOnSearchInput) + wsSearchInput?.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { e.stopPropagation(); wsPicker.hidden = true } + }) - function renderBreadcrumb(dir) { - breadcrumb.innerHTML = '' - if (!dir) { - // 根盘符视图 - const span = document.createElement('span') - span.className = 'workspace-picker-breadcrumb-item' - span.textContent = '盘符' - span.title = '根盘符' - breadcrumb.appendChild(span) + // ---- 创建或打开新空间 → showDirectoryPicker (Chromium) / webkitdirectory 回退 ---- + wsCreateBtn?.addEventListener('click', async (e) => { + e.stopPropagation() + wsPicker.hidden = true + + const result = await pickDirectory() + if (!result.ok) { + if (result.reason === 'cancel') return + if (result.reason === 'multiple' && result.candidates) { + _showWorkspaceCandidates(result.candidates) + return + } + showToast(result.error || '目录选择失败', 3000) return } - // 拆路径:["C:", "Users", "", ...] - const sep = dir.includes('\\') ? '\\' : '/' - const isWin = dir.match(/^[A-Z]:/i) - const parts = dir.split(/[\\\/]/).filter(Boolean) - let acc = '' - if (isWin) { - acc = parts.shift() + sep // "C:\" - } else { - acc = sep // "/" - } - const firstCrumb = document.createElement('span') - firstCrumb.className = 'workspace-picker-breadcrumb-item' - firstCrumb.textContent = acc - firstCrumb.title = isWin ? '回到根盘符' : '/' - // 关键修复:firstCrumb 始终跳回根盘符视图(Windows = loadBrowse(null),Linux = loadBrowse('/')) - firstCrumb.addEventListener('click', (e) => { - e.stopPropagation() - loadBrowse(isWin ? null : '/') + + // showDirectoryPicker 直接返回绝对路径 + submitWorkspaceChange({ dir: result.dir, syncTui: false }) + }) + + // ---- 多候选选择层 ---- + function _showWorkspaceCandidates(candidates) { + // 先关闭主 picker,打开候选列表弹层(复用 wsPicker 区域) + const listEl = document.getElementById('ws-picker-recent-list') + if (!listEl) return + listEl.innerHTML = '
找到多个同名目录,选择一个:
' + candidates.forEach(c => { + const div = document.createElement('div') + div.className = 'ws-picker-recent-item' + div.style.cssText = 'padding:8px 10px;cursor:pointer;border-radius:6px;font-size:13px;display:flex;align-items:center;gap:8px' + div.innerHTML = `📁${escapeHtml(c.path)}` + div.addEventListener('click', () => { + submitWorkspaceChange({ dir: c.path, syncTui: false }) + }) + listEl.appendChild(div) }) - breadcrumb.appendChild(firstCrumb) - // 关键修复:第一个 part 之前不加 sep1(firstCrumb 已经以 sep 结尾,否则会出现 "C:\\" 双反斜杠) - let isFirst = true - for (const p of parts) { - if (!isFirst) { - const sep1 = document.createElement('span') - sep1.className = 'workspace-picker-breadcrumb-sep' - sep1.textContent = sep - breadcrumb.appendChild(sep1) - } - isFirst = false - acc = joinPath(acc, p, sep) - const item = document.createElement('span') - item.className = 'workspace-picker-breadcrumb-item' - item.textContent = p - item.title = acc - item.addEventListener('click', (e) => { e.stopPropagation(); loadBrowse(acc) }) - breadcrumb.appendChild(item) - } - } - function joinPath(base, part, sep) { - if (base.endsWith(sep)) return base + part - return base + sep + part + // 添加取消按钮 + const cancelBtn = document.createElement('button') + cancelBtn.textContent = '取消' + cancelBtn.style.cssText = 'margin-top:8px;width:100%;padding:8px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;cursor:pointer;color:var(--text)' + cancelBtn.addEventListener('click', () => { + listEl.innerHTML = '
…
' + wsLoadRecent('') + }) + listEl.appendChild(cancelBtn) + // 打开 picker 显示候选 + wsPicker.hidden = false } - function renderTreeNodes(parent, children, currentDir) { - parent.innerHTML = '' - if (!children || children.length === 0) { - const empty = document.createElement('div') - empty.className = 'workspace-picker-tree-empty' - empty.textContent = '(无子目录)' - parent.appendChild(empty) - return - } - for (const c of children) { - const node = document.createElement('div') - node.className = 'workspace-tree-node' - if (c.path === currentDir) node.classList.add('current') - const chev = document.createElement('span') - chev.className = 'workspace-tree-chevron' - chev.textContent = '▶' - const name = document.createElement('span') - name.className = 'workspace-tree-name' - name.textContent = c.name - name.title = c.path - const setBtn = document.createElement('button') - setBtn.className = 'workspace-tree-set-btn' - setBtn.textContent = '选' - setBtn.title = '切换到此目录' - const childrenBox = document.createElement('div') - childrenBox.className = 'workspace-tree-children' - childrenBox.hidden = true - - // 展开 / 收起 - chev.addEventListener('click', async (e) => { - e.stopPropagation() - if (childrenBox.hidden) { - // 展开 - if (!childrenBox.dataset.loaded) { - chev.classList.add('loading') - chev.textContent = '…' - const data = await fetchBrowse(c.path) - chev.classList.remove('loading') - chev.textContent = '▶' - if (data.ok) { - childrenBox.innerHTML = '' - // 递归:子节点也用同样渲染(但要传入 currentDir 包含关系判断) - renderTreeNodes(childrenBox, data.children, currentDir) - childrenBox.dataset.loaded = '1' - } else { - const err = document.createElement('div') - err.className = 'workspace-picker-tree-error' - err.textContent = '加载失败: ' + (data.error || '未知') - childrenBox.innerHTML = '' - childrenBox.appendChild(err) - } - } - childrenBox.hidden = false - chev.classList.add('open') - } else { - childrenBox.hidden = true - chev.classList.remove('open') - } - }) - // 点击 name = 进入该目录(重渲染树为该目录的子目录) - name.addEventListener('click', (e) => { - e.stopPropagation() - loadBrowse(c.path) - }) - // 选 = 切换工作区 - setBtn.addEventListener('click', (e) => { - e.stopPropagation() - submitWorkspaceChange({ dir: c.path, syncTui: wsSyncCheckbox.checked }) - }) + // ---- 无需工作空间 → 临时目录 ---- + wsNoWsBtn?.addEventListener('click', (e) => { + e.stopPropagation() + const tmp = wsTmpDir || '' + if (!tmp) { showToast(t('ws_nows_unavailable') || '临时目录不可用'); return } + wsPicker.hidden = true + submitWorkspaceChange({ dir: tmp, syncTui: false }) + }) - node.appendChild(chev) - node.appendChild(name) - node.appendChild(setBtn) - parent.appendChild(node) - parent.appendChild(childrenBox) - } + // ---- wsPicker 自身 Escape 关闭 ---- + wsPicker?.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { e.stopPropagation(); wsPicker.hidden = true } + }) + + // ---- quick picker(chip 下拉)---- + const wsQuickPicker = document.getElementById('ws-quick-picker') + const wsQuickListEl = document.getElementById('ws-quick-list') + const wsQuickAddBtn = document.getElementById('ws-quick-add') + + // ---- 欢迎页 chip → quick picker ---- + if (emptyWsBtn) { + emptyWsBtn.addEventListener('click', (e) => { + e.stopPropagation() + const hasMessages = (state?.chat?.length || 0) > 0 + if (hasMessages) { + showToast(t('workspace_locked_in_chat')) + return + } + openWsPicker() // 打开完整的 workspace-picker(含搜索框 + 创建/打开新空间按钮) + }) } - async function loadBrowse(path) { - browseCurrent = path - renderBreadcrumb(path) - treeEl.innerHTML = '
加载中…
' - const data = await fetchBrowse(path) - if (!data.ok) { - treeEl.innerHTML = `
加载失败: ${data.error || '未知'}
` - return - } - treeEl.innerHTML = '' - if (data.roots) { - // 根盘符视图 - const cur = (state && state.workspace && state.workspace.dir) || '' - renderTreeNodes(treeEl, data.roots.map(r => ({ name: r, path: r })), cur) - } else { + function openWsQuickPicker() { + const picker = wsQuickPicker + if (!picker) return + + // 定位:相对于 emptyWsBtn + const anchor = emptyWsBtn + if (!anchor) return + const rect = anchor.getBoundingClientRect() + picker.style.top = (rect.bottom + window.scrollY + 4) + 'px' + picker.style.left = (rect.left + window.scrollX) + 'px' + + if (wsQuickListEl) wsQuickListEl.innerHTML = '
…
' + picker.hidden = false + + wsLoadRecent('').then(() => { + if (picker.hidden) return + let html = '' const cur = (state && state.workspace && state.workspace.dir) || '' - renderTreeNodes(treeEl, data.children, cur) - } + if (wsRecentCache.length > 0) { + wsRecentCache.slice(0, 5).forEach(item => { + const active = item.dir === cur ? ' active' : '' + html += `
+ ${escapeHtml(wsShortName(item.dir) || item.dir)} + ${item.count || 0} +
` + }) + } else { + html += `
${escapeHtml(t('no_workspaces') || '暂无工作区')}
` + } + if (wsQuickListEl) wsQuickListEl.innerHTML = html + + // 点击已有工作区 = 切换 + if (wsQuickListEl) { + wsQuickListEl.querySelectorAll('.ws-quick-item').forEach(item => { + item.addEventListener('click', () => { + hideWsQuickPicker() + submitWorkspaceChange({ dir: item.dataset.dir, syncTui: false }) + }) + }) + } + }).catch(() => { + if (wsQuickListEl && !picker.hidden) wsQuickListEl.innerHTML = '
加载失败
' + }) } - browseToggle.addEventListener('click', async (e) => { - e.stopPropagation() - browseOpen = !browseOpen - browsePanel.hidden = !browseOpen - browseToggle.classList.toggle('open', browseOpen) - if (browseOpen) { - // 初始:从当前工作区(或根盘符)开始 - const cur = (state && state.workspace && state.workspace.dir) || null - await loadBrowse(cur) + function hideWsQuickPicker() { + if (wsQuickPicker) wsQuickPicker.hidden = true + } + + // 点击 quick picker 外部关闭 + document.addEventListener('click', (e) => { + if (wsQuickPicker && !wsQuickPicker.hidden && !wsQuickPicker.contains(e.target)) { + const anchor = emptyWsBtn + if (!anchor || !anchor.contains(e.target)) hideWsQuickPicker() } }) + // 「添加工作区」按钮(HTML 中的静态按钮) + wsQuickAddBtn?.addEventListener('click', (e) => { + e.stopPropagation() + hideWsQuickPicker() + openWsPicker() + }) + // 点击 popover 外关掉 document.addEventListener('click', (e) => { if (wsPicker && !wsPicker.hidden && !wsPicker.contains(e.target) && e.target.id !== 'chip-workspace' && !(wsChip && wsChip.contains(e.target))) { wsPicker.hidden = true } }) - // 窗口 resize / scroll 重新定位 - window.addEventListener('resize', () => { if (!wsPicker.hidden) positionWsPicker() }) // v0.5.bh: 监听网络状态变化,实时更新顶栏在线指示 window.addEventListener('online', () => { try { render() } catch (e) {} }) @@ -1390,11 +1332,14 @@ export function attachEvents() { // New chat // v0.5.ar: 新建会话 → 弹工作区选择 popover(选完工作区再调 /api/sessions) + // v0.5.bx-33 (v1.2): 已删除 new-chat-ws-picker,改用 chat-empty-workspace chip 下拉 const newChatPicker = document.getElementById('new-chat-ws-picker') const newChatCurrent = document.getElementById('new-chat-current-ws') const newChatList = document.getElementById('new-chat-ws-list') const newChatCancel = document.getElementById('new-chat-ws-cancel') const newChatOther = document.getElementById('new-chat-ws-other') + // 仅在元素存在时初始化(兼容旧版 picker,v1.2+ 已移除) + if (newChatPicker) { function positionNewChatPicker() { const btn = document.getElementById('btn-new-chat') if (!btn) return @@ -1461,6 +1406,7 @@ export function attachEvents() { newChatPicker.hidden = true } }) + } // end if (newChatPicker) async function createNewSession(workspace) { // v0.5.ak: mcode 还在跑时禁止清空 chat @@ -1509,7 +1455,7 @@ export function attachEvents() { } // 关掉所有 popover document.querySelectorAll('.mode-popover, .settings-menu, .model-picker, .workspace-picker, .new-chat-picker').forEach(el => { el.hidden = true }) - newChatPicker.hidden = true + if (newChatPicker) newChatPicker.hidden = true render() } diff --git a/public/app/fs-picker.css b/public/app/fs-picker.css new file mode 100644 index 0000000..7ec16eb --- /dev/null +++ b/public/app/fs-picker.css @@ -0,0 +1,404 @@ +/* ============================================================ + fs-picker.css — 目录选择对话框样式 + ============================================================ */ + +/* Overlay */ +.fs-picker-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 9000; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 13px; + color: #222; +} + +.fs-picker-overlay.fs-picker-open { + opacity: 1; + pointer-events: all; +} + +/* Dialog */ +.fs-picker-dialog { + background: #fff; + border-radius: 10px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25), 0 4px 16px rgba(0, 0, 0, 0.15); + width: 780px; + max-width: 95vw; + max-height: 85vh; + display: flex; + flex-direction: column; + overflow: hidden; + transform: scale(0.95); + transition: transform 0.2s ease; +} + +.fs-picker-overlay.fs-picker-open .fs-picker-dialog { + transform: scale(1); +} + +/* Toolbar */ +.fs-picker-toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 12px; + background: #f7f8fa; + border-bottom: 1px solid #e4e7ed; + flex-shrink: 0; +} + +.fs-picker-breadcrumb { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; +} + +.fs-picker-sep { + width: 1px; + height: 20px; + background: #d0d5dd; + margin: 0 4px; + flex-shrink: 0; +} + +.fs-picker-path-wrap { + flex: 1; + min-width: 0; +} + +.fs-picker-path { + width: 100%; + height: 30px; + border: 1px solid #d0d5dd; + border-radius: 6px; + padding: 0 10px; + font-size: 13px; + font-family: 'SF Mono', 'Monaco', 'Cascadia Code', monospace; + background: #fff; + color: #222; + box-sizing: border-box; + outline: none; + transition: border-color 0.15s; +} + +.fs-picker-path:focus { + border-color: #4a9eff; + box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.2); +} + +/* Filter */ +.fs-picker-filter-wrap { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: #fafbfc; + border-bottom: 1px solid #eaecf0; + flex-shrink: 0; +} + +.fs-picker-filter { + flex: 1; + height: 28px; + border: 1px solid #d0d5dd; + border-radius: 6px; + padding: 0 10px; + font-size: 12px; + background: #fff; + outline: none; + transition: border-color 0.15s; +} + +.fs-picker-filter:focus { + border-color: #4a9eff; + box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.15); +} + +/* Column headers */ +.fs-picker-headers { + display: flex; + align-items: center; + padding: 0 12px; + height: 32px; + background: #f0f2f5; + border-bottom: 1px solid #e4e7ed; + flex-shrink: 0; + font-weight: 600; + font-size: 12px; + color: #666; + user-select: none; +} + +.fs-col { + flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fs-col-check { width: 36px; text-align: center; } +.fs-col-name { flex: 1; min-width: 0; padding-left: 4px; } +.fs-col-size { width: 90px; text-align: right; padding-right: 12px; } +.fs-col-mtime { width: 140px; text-align: left; padding-left: 8px; } +.fs-col-mode { width: 90px; text-align: left; padding-left: 8px; font-family: 'SF Mono', monospace; font-size: 11px; } + +/* File list */ +.fs-picker-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + background: #fff; +} + +.fs-picker-loading, +.fs-picker-empty, +.fs-picker-error { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + height: 100px; + color: #888; + font-size: 13px; +} + +.fs-picker-error { + color: #e03e3e; +} + +/* Entry row */ +.fs-entry { + display: flex; + align-items: center; + padding: 0 12px; + height: 34px; + border-bottom: 1px solid #f0f0f0; + cursor: default; + user-select: none; + transition: background 0.1s; +} + +.fs-entry:hover { + background: #f5f7ff; +} + +.fs-entry:focus { + outline: none; + background: #e8f0ff; +} + +.fs-entry.fs-selected { + background: #e8f4ff; +} + +.fs-entry.fs-type-dir .fs-col-name { + color: #1a56a0; + font-weight: 500; +} + +/* Entry checkbox */ +.fs-col-check { + display: flex; + align-items: center; + justify-content: center; +} + +.fs-entry-check { + width: 15px; + height: 15px; + cursor: pointer; + accent-color: #4a9eff; +} + +/* Entry name */ +.fs-col-name { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; +} + +.fs-icon { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.fs-entry-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Entry meta columns */ +.fs-col-size, +.fs-col-mtime, +.fs-col-mode { + font-size: 12px; + color: #888; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Status bar */ +.fs-picker-status { + height: 28px; + display: flex; + align-items: center; + padding: 0 14px; + background: #f7f8fa; + border-top: 1px solid #e4e7ed; + font-size: 12px; + color: #888; + flex-shrink: 0; +} + +/* Buttons */ +.fs-btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 30px; + padding: 0 14px; + border-radius: 6px; + border: 1px solid #d0d5dd; + background: #fff; + color: #374151; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; + white-space: nowrap; + flex-shrink: 0; +} + +.fs-btn:hover { + background: #f3f4f6; + border-color: #b0b7c3; +} + +.fs-btn:active { + background: #e8eaed; +} + +.fs-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.fs-btn-primary { + background: #4a9eff; + border-color: #3b8ef5; + color: #fff; +} + +.fs-btn-primary:hover { + background: #3b8ef5; + border-color: #2a7ee5; +} + +.fs-btn-primary:active { + background: #2a7ee5; +} + +.fs-btn-primary:disabled { + background: #a0c8f8; + border-color: #90bcf0; + color: #fff; +} + +.fs-btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 6px; + border: none; + background: transparent; + color: #555; + cursor: pointer; + transition: background 0.15s, color 0.15s; + flex-shrink: 0; +} + +.fs-btn-icon:hover { + background: #e8eaf0; + color: #222; +} + +.fs-btn-icon:active { + background: #dce0ea; +} + +.fs-btn-icon svg { + width: 18px; + height: 18px; +} + +/* Toolbar right side */ +.fs-picker-toolbar-right { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +/* Spinner */ +.fs-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid #d0d5dd; + border-top-color: #4a9eff; + border-radius: 50%; + animation: fs-spin 0.7s linear infinite; +} + +@keyframes fs-spin { + to { transform: rotate(360deg); } +} + +/* Scrollbar */ +.fs-picker-list::-webkit-scrollbar { + width: 8px; +} +.fs-picker-list::-webkit-scrollbar-track { + background: #f5f5f5; +} +.fs-picker-list::-webkit-scrollbar-thumb { + background: #cdcdcd; + border-radius: 4px; +} +.fs-picker-list::-webkit-scrollbar-thumb:hover { + background: #b0b0b0; +} + +/* Filter clear */ +.fs-picker-filter-clear { + width: 24px; + height: 24px; +} + +/* Responsive */ +@media (max-width: 600px) { + .fs-col-size, + .fs-col-mtime, + .fs-col-mode { + display: none; + } + .fs-picker-dialog { + width: 98vw; + max-height: 95vh; + } +} diff --git a/public/app/fs-picker.js b/public/app/fs-picker.js new file mode 100644 index 0000000..db7f331 --- /dev/null +++ b/public/app/fs-picker.js @@ -0,0 +1,627 @@ +/** + * fs-picker.js — 原生风格目录选择对话框 + * + * 功能: + * - 地址栏:用户目录 / 根目录 / 新建文件夹 按钮 + 可编辑地址栏 + 确定/取消按钮 + * - 目录列表:双击打开目录、默认置顶 ".." 返回上级、子条目多选和过滤 + * - 与后端 /api/fs/read 配合,支持 Electron 模式降级到 window.dialog + */ + +(function () { + 'use strict'; + + // ============================================================ + // 工具函数 + // ============================================================ + + /** 解析路径的父目录 */ + function parentPath(path) { + if (!path || path === '/') return null; + const segs = path.replace(/\\/g, '/').split('/').filter(Boolean); + segs.pop(); + const parent = segs.length ? '/' + segs.join('/') : '/'; + return parent; + } + + /** 从路径提取文件名 */ + function baseName(path) { + if (!path || path === '/') return '/'; + const segs = path.replace(/\\/g, '/').split('/').filter(Boolean); + return segs[segs.length - 1] || '/'; + } + + /** 路径末尾加斜杠(保证一致性) */ + function joinPath(parent, name) { + const p = parent.replace(/\\/g, '/').replace(/\/+$/, ''); + if (name === '..') return parentPath(p) || '/'; + return p + '/' + name; + } + + /** 权限位数字转 rwx 字符串 */ + function modeString(mode) { + if (mode == null) return '---------'; + const bits = [ + (mode & 0x100) ? 'r' : '-', + (mode & 0x80) ? 'w' : '-', + (mode & 0x40) ? 'x' : '-', + (mode & 0x20) ? 'r' : '-', + (mode & 0x10) ? 'w' : '-', + (mode & 0x8) ? 'x' : '-', + (mode & 0x4) ? 'r' : '-', + (mode & 0x2) ? 'w' : '-', + (mode & 0x1) ? 'x' : '-', + ]; + return bits.join(''); + } + + /** 格式化文件大小 */ + function formatSize(bytes) { + if (bytes == null || bytes === '-') return '-'; + const n = Number(bytes); + if (isNaN(n)) return '-'; + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'; + return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB'; + } + + /** 格式化修改时间 */ + function formatMtime(mtime) { + if (!mtime) return '-'; + const d = new Date(mtime); + if (isNaN(d.getTime())) return '-'; + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + } + + // ============================================================ + // 主类 FsPicker + // ============================================================ + + class FsPicker { + constructor(options = {}) { + this.options = options; + this.currentPath = options.defaultPath || this._getHomeDir(); + this.selectedPaths = new Set(); + this.entries = []; + this.filterText = ''; + this._home = null; // 用户目录,从后端 API 获取 + this._visible = false; + this._resolve = null; // Promise resolve + this._reject = null; // Promise reject + + this._render(); + this._bindEvents(); + this._load(this.currentPath); + } + + // ---------------------------------------------------------- + // 公开 API + // ---------------------------------------------------------- + + /** 显示对话框并返回 Promise */ + async open() { + this._reset(); + this._show(); + return new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + } + + /** 销毁对话框(内部调用) */ + destroy() { + if (this._overlay && this._overlay.parentNode) { + this._overlay.parentNode.removeChild(this._overlay); + } + } + + // ---------------------------------------------------------- + // 初始化 & 渲染 + // ---------------------------------------------------------- + + _render() { + // Overlay + this._overlay = document.createElement('div'); + this._overlay.className = 'fs-picker-overlay'; + this._overlay.innerHTML = ` + + `; + document.body.appendChild(this._overlay); + + // Cache DOM refs + this._pathInput = this._overlay.querySelector('.fs-picker-path'); + this._filterInput = this._overlay.querySelector('.fs-picker-filter'); + this._filterClear = this._overlay.querySelector('.fs-picker-filter-clear'); + this._listEl = this._overlay.querySelector('.fs-picker-list'); + this._loadingEl = this._overlay.querySelector('.fs-picker-loading'); + this._emptyEl = this._overlay.querySelector('.fs-picker-empty'); + this._errorEl = this._overlay.querySelector('.fs-picker-error'); + this._statusEl = this._overlay.querySelector('.fs-picker-status'); + this._confirmBtn = this._overlay.querySelector('[data-action="confirm"]'); + + // 初始路径 + this._pathInput.value = this.currentPath; + } + + _bindEvents() { + const dlg = this._overlay.querySelector('.fs-picker-dialog'); + + // Toolbar buttons + dlg.querySelector('[data-action="home"]').addEventListener('click', () => { + this._load(this._getHomeDir()); + }); + dlg.querySelector('[data-action="root"]').addEventListener('click', () => { + this._load('/'); + }); + dlg.querySelector('[data-action="mkdir"]').addEventListener('click', () => { + this._mkdir(); + }); + dlg.querySelector('[data-action="cancel"]').addEventListener('click', () => { + this._close(null); + }); + dlg.querySelector('[data-action="confirm"]').addEventListener('click', () => { + this._confirm(); + }); + dlg.querySelector('[data-action="clear-filter"]').addEventListener('click', () => { + this._filterInput.value = ''; + this.filterText = ''; + this._filterClear.style.display = 'none'; + this._renderEntries(); + }); + + // Path input: Enter 导航 + this._pathInput.addEventListener('keydown', e => { + if (e.key === 'Enter') { + const val = e.target.value.trim(); + if (val) this._load(val); + } + if (e.key === 'Escape') { + e.target.value = this.currentPath; + e.target.blur(); + } + }); + + // Filter input + this._filterInput.addEventListener('input', e => { + this.filterText = e.target.value.trim(); + this._filterClear.style.display = this.filterText ? 'flex' : 'none'; + this._renderEntries(); + }); + + // Overlay 点击关闭 + this._overlay.addEventListener('click', e => { + if (e.target === this._overlay) this._close(null); + }); + + // Keyboard 快捷键 + document.addEventListener('keydown', this._keyHandler = e => { + if (!this._visible) return; + if (e.key === 'Escape') { + e.preventDefault(); + this._close(null); + } + if (e.key === 'Enter' && document.activeElement === this._pathInput) { + const val = this._pathInput.value.trim(); + if (val) this._load(val); + } + }); + } + + // ---------------------------------------------------------- + // 公共 API + // ---------------------------------------------------------- + + /** 打开对话框,返回 Promise<{ canceled: boolean, path?: string }> */ + pick() { + return new Promise((resolve) => { + this._resolve = resolve; + this._reset(); + this._show(); + // 立即加载初始目录 + this._load(this.currentPath); + }); + } + + // ---------------------------------------------------------- + // 状态管理 + // ---------------------------------------------------------- + + _reset() { + this.currentPath = this.options.defaultPath || this._getHomeDir(); + this.selectedPaths.clear(); + this.entries = []; + this.filterText = ''; + this._pathInput.value = this.currentPath; + this._filterInput.value = ''; + this._filterClear.style.display = 'none'; + this._confirmBtn.disabled = true; + this._updateStatus(); + } + + _show() { + this._overlay.classList.add('fs-picker-open'); + this._visible = true; + this._pathInput.focus(); + this._pathInput.select(); + } + + _close(result) { + this._visible = false; + this._overlay.classList.remove('fs-picker-open'); + document.removeEventListener('keydown', this._keyHandler); + if (this._resolve) { + const res = this._resolve; + this._resolve = null; + this._reject = null; + // 统一返回 { canceled, path } 格式 + if (result === null || result === undefined) { + res({ canceled: true }); + } else if (typeof result === 'string') { + res({ canceled: false, path: result }); + } else { + res(result); + } + } + // 动画结束后销毁 DOM + setTimeout(() => this.destroy(), 300); + } + + _confirm() { + const paths = [...this.selectedPaths]; + if (!paths.length) return; + // 如果是单选目录且选中了目录本身,直接返回路径 + const result = paths.length === 1 && paths[0].endsWith('/') ? paths[0] : paths; + this._close(result); + } + + _getHomeDir() { + return this._home || '/'; + } + + // ---------------------------------------------------------- + // 目录加载 & API + // ---------------------------------------------------------- + + async _load(path) { + // 标准化路径 + path = this._normPath(path); + this._showLoading(true); + this._showError(''); + this.selectedPaths.clear(); + this._confirmBtn.disabled = true; + + try { + const data = await this._readDir(path); + this.currentPath = path; + this.entries = data; + this._pathInput.value = path; + this._updateStatus(); + this._renderEntries(); + } catch (err) { + this._showError('加载目录失败: ' + (err.message || err)); + this.entries = []; + this._renderEntries(); + } finally { + this._showLoading(false); + } + } + + async _readDir(path) { + const url = `/api/fs/read?path=${encodeURIComponent(path)}&t=${Date.now()}`; + const resp = await fetch(url); + if (!resp.ok) { + const txt = await resp.text().catch(() => ''); + throw new Error(`${resp.status} ${resp.statusText}${txt ? ': ' + txt : ''}`); + } + const json = await resp.json(); + if (json.error) throw new Error(json.error); + // 首次加载时从响应中获取用户目录 + if (json.home) this._home = json.home; + return json.data || []; + } + + async _mkdir() { + const name = prompt('输入文件夹名称:'); + if (!name || !name.trim()) return; + const parent = this.currentPath; + const url = '/api/fs/mkdir'; + let resp; + try { + resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: parent, name: name.trim() }), + }); + } catch (err) { + alert('创建文件夹失败: ' + err.message); + return; + } + const json = await resp.json().catch(() => ({})); + if (json.error) { + alert('创建失败: ' + json.error); + return; + } + // 刷新当前目录 + this._load(parent); + } + + // ---------------------------------------------------------- + // 列表渲染 + // ---------------------------------------------------------- + + _renderEntries() { + // 移除旧条目(保留 loading/empty/error) + const oldItems = this._listEl.querySelectorAll('.fs-entry'); + oldItems.forEach(el => el.remove()); + + const all = this.entries; + if (!all.length) { + this._emptyEl.style.display = 'block'; + return; + } + this._emptyEl.style.display = 'none'; + + // glob 过滤 + let items = all; + if (this.filterText) { + items = this._filterGlob(all, this.filterText); + } + + if (!items.length) { + this._emptyEl.textContent = this.filterText ? '没有匹配的结果' : '该目录为空'; + this._emptyEl.style.display = 'block'; + return; + } + + // 排序:目录优先,然后按名称 + const dirs = items.filter(e => e.type === 'dir'); + const files = items.filter(e => e.type !== 'dir'); + dirs.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + files.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + const sorted = [...dirs, ...files]; + + sorted.forEach(entry => { + const row = this._makeEntryRow(entry); + this._listEl.appendChild(row); + }); + } + + _makeEntryRow(entry) { + const row = document.createElement('div'); + row.className = 'fs-entry fs-type-' + (entry.type || 'file'); + row.setAttribute('role', 'option'); + row.setAttribute('data-path', entry.path); + row.setAttribute('data-type', entry.type || 'file'); + row.setAttribute('data-name', entry.name); + row.setAttribute('tabindex', '0'); + + const isDir = entry.type === 'dir'; + const isSelected = this.selectedPaths.has(entry.path); + if (isSelected) row.classList.add('fs-selected'); + + // Checkbox + const check = document.createElement('div'); + check.className = 'fs-col fs-col-check'; + check.innerHTML = ``; + row.appendChild(check); + + // Icon + Name + const nameCol = document.createElement('div'); + nameCol.className = 'fs-col fs-col-name'; + const icon = isDir + ? `` + : ``; + nameCol.innerHTML = `${icon}${this._escHtml(entry.name)}`; + row.appendChild(nameCol); + + // Size + const sizeCol = document.createElement('div'); + sizeCol.className = 'fs-col fs-col-size'; + sizeCol.textContent = isDir ? '-' : formatSize(entry.size); + row.appendChild(sizeCol); + + // Mtime + const mtimeCol = document.createElement('div'); + mtimeCol.className = 'fs-col fs-col-mtime'; + mtimeCol.textContent = formatMtime(entry.mtime); + row.appendChild(mtimeCol); + + // Mode + const modeCol = document.createElement('div'); + modeCol.className = 'fs-col fs-col-mode'; + modeCol.textContent = modeString(entry.mode); + row.appendChild(modeCol); + + // 双击打开目录 + if (isDir) { + row.addEventListener('dblclick', () => { + this._load(entry.path); + }); + } + + // Checkbox 切换选择 + const checkbox = check.querySelector('input'); + checkbox.addEventListener('click', e => { + e.stopPropagation(); + this._toggleSelect(entry, checkbox.checked); + }); + // 点击行选中/反选(非目录)或打开(目录) + row.addEventListener('click', e => { + if (e.target === checkbox) return; + if (isDir) { + // 目录:点一行=选中该目录 + this._toggleSelect(entry, !isSelected); + } else { + // 文件:点一行=选中 + this._toggleSelect(entry, !isSelected); + } + }); + + // 空格/回车 选中 + row.addEventListener('keydown', e => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + if (isDir) { + this._load(entry.path); + } else { + this._toggleSelect(entry, !this.selectedPaths.has(entry.path)); + } + } + }); + + return row; + } + + _toggleSelect(entry, on) { + if (on) { + this.selectedPaths.add(entry.path); + } else { + this.selectedPaths.delete(entry.path); + } + // 更新行样式和 checkbox + const row = this._listEl.querySelector(`[data-path="${CSS.escape(entry.path)}"]`); + if (row) { + row.classList.toggle('fs-selected', on); + const cb = row.querySelector('.fs-entry-check'); + if (cb) cb.checked = on; + } + this._confirmBtn.disabled = this.selectedPaths.size === 0; + this._updateStatus(); + } + + // ---------------------------------------------------------- + // glob 过滤(轻量,不依赖外部库) + // ---------------------------------------------------------- + + _filterGlob(entries, pattern) { + // 简单实现:* 匹配任意字符,? 匹配单个字符 + const re = this._globToRegex(pattern); + return entries.filter(e => re.test(e.name)); + } + + _globToRegex(pattern) { + let regex = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex special + .replace(/\*/g, '.*') + .replace(/\?/g, '.'); + return new RegExp('^' + regex + '$', 'i'); + } + + // ---------------------------------------------------------- + // 辅助 UI + // ---------------------------------------------------------- + + _showLoading(show) { + this._loadingEl.style.display = show ? 'flex' : 'none'; + this._emptyEl.style.display = 'none'; + this._errorEl.style.display = 'none'; + } + + _showError(msg) { + if (msg) { + this._errorEl.textContent = msg; + this._errorEl.style.display = 'block'; + this._emptyEl.style.display = 'none'; + } else { + this._errorEl.style.display = 'none'; + } + } + + _updateStatus() { + const n = this.selectedPaths.size; + if (n === 0) { + this._statusEl.textContent = ''; + } else { + this._statusEl.textContent = `已选择 ${n} 项`; + } + } + + _normPath(path) { + if (!path) return '/'; + path = path.trim().replace(/\\/g, '/'); + if (!path.startsWith('/')) { + // 相对路径:拼到当前路径 + path = this.currentPath.replace(/\/$/, '') + '/' + path; + } + // 解析 .. 和 . + const segs = path.split('/').filter(Boolean); + const resolved = []; + for (const seg of segs) { + if (seg === '.') continue; + if (seg === '..') { resolved.pop(); continue; } + resolved.push(seg); + } + return '/' + resolved.join('/'); + } + + _escHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + } + + // ============================================================ + // 暴露全局函数 + // ============================================================ + + /** 对外入口:pickDirectory() 使用 fs-picker 兜底 */ + window.FsPicker = FsPicker; + +})(); diff --git a/public/app/i18n.js b/public/app/i18n.js index 3febac9..83f8606 100644 --- a/public/app/i18n.js +++ b/public/app/i18n.js @@ -175,6 +175,13 @@ const I18N = { workspace_reset: '恢复 webui 启动时检测到的默认工作区', workspace_locked_in_chat: '对话已开始,工作区已锁定。点击左侧「新建会话」可重新选择工作区。', workspace_picker_hint: '点击选择工作区', + // v1.2 (feat-workspace-lhl): 零弹窗目录选择 i18n + native_candidates_title: '找到多个匹配目录:', + native_candidates_none: '未找到匹配目录,已打开目录树', + native_picked_fill: '目录选择失败,请尝试手动输入路径', + ws_nows_btn: '无需工作空间', + ws_nows_unavailable: '临时目录不可用', + ws_add: '添加工作区…', // v0.5.bx-8: ask_user 工具 (mcode 0.1.4 ask_user) — 学 mavis 桌面端弹窗布局文案 ask_user_other_placeholder: '其他...', ask_user_clear: '清空', @@ -427,6 +434,13 @@ const I18N = { workspace_reset: 'Reset to default workspace detected at startup', workspace_locked_in_chat: 'Chat already started — workspace locked. Click "New Chat" in the sidebar to pick a new workspace.', workspace_picker_hint: 'Click to select workspace', + // v1.2 (feat-workspace-lhl): zero-dialog directory selection i18n + native_candidates_title: 'Multiple matching directories found:', + native_candidates_none: 'No matching directory found — opened built-in tree', + native_picked_fill: 'Directory selection failed, try entering the path manually', + ws_nows_btn: 'No workspace needed', + ws_nows_unavailable: 'Temporary directory unavailable', + ws_add: 'Add workspace…', // v0.5.bx-8: ask_user 工具 (mcode 0.1.4 ask_user) — 学 mavis 桌面端弹窗布局文案 ask_user_other_placeholder: 'Other...', ask_user_clear: 'Clear', diff --git a/public/app/native-fs.js b/public/app/native-fs.js new file mode 100644 index 0000000..1f9b384 --- /dev/null +++ b/public/app/native-fs.js @@ -0,0 +1,26 @@ +// webui/public/app/native-fs.js — v5.0 (feat-workspace-lhl) +// +// 统一使用 fs-picker 对话框作为目录选择器。 +// 自绘原生风格:地址栏(可编辑)+ 分列列表 + 创建文件夹,通过后端 /api/fs/read 获取目录内容。 + +// 主入口:打开目录选择对话框。 +// 返回 Promise<{ ok: true, dir: string } | { ok: false, reason: 'cancel' | 'error', error?: string }> +export async function pickDirectory() { + try { + if (typeof window.FsPicker !== 'function') { + return { ok: false, reason: 'error', error: 'FsPicker 类未加载' } + } + const picker = new window.FsPicker({ + title: '选择工作目录', + startPath: '', + }) + const result = await picker.pick() + if (result.canceled) return { ok: false, reason: 'cancel' } + if (result.path) return { ok: true, dir: result.path } + return { ok: false, reason: 'error', error: '未选择任何目录' } + } catch (e) { + const msg = e && e.message ? e.message : String(e) + if (msg === 'cancel') return { ok: false, reason: 'cancel' } + return { ok: false, reason: 'error', error: msg } + } +} diff --git a/public/app/render.js b/public/app/render.js index 2ef2987..cb0f64b 100644 --- a/public/app/render.js +++ b/public/app/render.js @@ -117,7 +117,8 @@ export function render() { if (state.context?.tps) { if (tpsEl) tpsEl.hidden = false - document.getElementById('tps-value').textContent = state.context.tps + const tpsValue = document.getElementById('tps-value') + if (tpsValue) tpsValue.textContent = state.context.tps } else { if (tpsEl) tpsEl.hidden = true } diff --git a/public/index.html b/public/index.html index c3be990..4d3d8a2 100644 --- a/public/index.html +++ b/public/index.html @@ -8,6 +8,8 @@ + +
@@ -332,7 +334,7 @@ (acp.mjs:155-160), mcode 实际仍用旧 model 跑 唯一能真切的路径: mcode exec (MCODE_USE_ACP=0) — runMcodeExec 真把 --model 透传给子进程 想临时恢复: 把下面 btn-model 标签的 hidden 去掉即可, server /api/set-model 端点没动 --> - @@ -379,36 +381,33 @@
- + @@ -418,6 +417,12 @@ … + +
/ 命令 · @ 文件 · Ctrl+V 粘贴图片 · Enter 发送 @@ -510,18 +515,8 @@
- - + @@ -706,6 +701,8 @@ + + diff --git a/public/styles/main.css b/public/styles/main.css index 0d50921..3a61e3f 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -314,6 +314,7 @@ a.chip-lan-link .icon { } /* v0.5.al: workspace picker popover(fixed 定位,跟随 chip-workspace) */ +/* v2.1 (feat-workspace-lhl): 美化样式 */ .workspace-picker { position: fixed; width: 360px; @@ -323,12 +324,24 @@ a.chip-lan-link .icon { overflow-y: auto; background: var(--bg-elevated, #fff); border: 1px solid var(--border); - border-radius: 10px; - padding: 14px; - box-shadow: 0 8px 32px rgba(0,0,0,0.18); + border-radius: 14px; + padding: 16px; + box-shadow: 0 12px 40px rgba(0,0,0,0.22), 0 4px 12px rgba(0,0,0,0.1); z-index: 200; + animation: picker-enter 0.18s ease-out; } -.workspace-picker-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; color: var(--text); } + +@keyframes picker-enter { + from { + opacity: 0; + transform: translateY(-6px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} +.workspace-picker-title { font-size: 15px; font-weight: 600; margin-bottom: 14px; color: var(--text); letter-spacing: -0.01em; } .workspace-picker-current { font-size: 11px; color: var(--text-tertiary); margin-bottom: 10px; padding: 6px 8px; background: var(--bg-input, var(--bg)); border-radius: 6px; @@ -345,13 +358,36 @@ a.chip-lan-link .icon { display: flex; gap: 6px; margin-top: 10px; } .btn-workspace-action { - flex: 1; padding: 6px 8px; background: transparent; border: 1px solid var(--border); - border-radius: 6px; color: var(--text-secondary); font-size: 12px; cursor: pointer; - transition: background 0.15s, border-color 0.15s, color 0.15s; + flex: 1; padding: 10px 14px; background: transparent; border: 1px solid var(--border); + border-radius: 10px; color: var(--text-secondary); font-size: 13px; cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.1s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + font-weight: 500; +} +.btn-workspace-action:hover { + background: var(--bg-hover); + border-color: var(--accent); + color: var(--text); +} +.btn-workspace-action:active { + transform: scale(0.98); +} +.btn-workspace-action.primary { + background: var(--accent); + color: var(--on-accent); + border-color: var(--accent); + font-weight: 600; +} +.btn-workspace-action.primary:hover { + background: var(--accent-hover); +} +.btn-workspace-action svg { + width: 16px; + height: 16px; } -.btn-workspace-action:hover { background: var(--bg-hover, rgba(0,0,0,0.04)); border-color: var(--accent); color: var(--text); } -.btn-workspace-action.primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); } -.btn-workspace-action.primary:hover { background: var(--accent-hover, #ffffff); } .workspace-picker-sync { display: flex; align-items: center; gap: 6px; margin-top: 10px; font-size: 11px; color: var(--text-tertiary); cursor: pointer; @@ -369,6 +405,162 @@ a.chip-lan-link .icon { .workspace-picker-recent:hover { background: var(--bg-hover, rgba(0,0,0,0.04)); color: var(--text); } .workspace-picker-hint { font-size: 11px; color: var(--text-tertiary); margin-top: 10px; } +/* v2 (feat-workspace-lhl): 搜索 + recent 列表 + 原生 picker */ +/* v2.1 美化样式 */ +.ws-picker-search-wrap { + position: relative; + margin-bottom: 12px; +} +.ws-picker-search-icon { + position: absolute; + left: 11px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + color: var(--text-tertiary); + pointer-events: none; +} +.ws-picker-search { + width: 100%; + padding: 10px 12px 10px 36px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-input, var(--bg)); + color: var(--text); + font-size: 14px; + box-sizing: border-box; + transition: border-color 0.15s, box-shadow 0.15s; +} +.ws-picker-search:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-bg); +} +.ws-picker-search::placeholder { color: var(--text-tertiary); } + +.ws-picker-recent-list { + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--border-light); + border-radius: 10px; + background: var(--bg); + margin-bottom: 12px; +} + +/* 自定义滚动条 */ +.ws-picker-recent-list::-webkit-scrollbar { + width: 5px; +} +.ws-picker-recent-list::-webkit-scrollbar-track { + background: transparent; +} +.ws-picker-recent-list::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} +.ws-picker-recent-list::-webkit-scrollbar-thumb:hover { + background: var(--text-tertiary); +} + +.ws-picker-recent-loading, +.ws-picker-recent-empty { + padding: 20px; + text-align: center; + font-size: 13px; + color: var(--text-tertiary); +} +.ws-picker-recent-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + cursor: pointer; + font-size: 13px; + color: var(--text); + transition: background 0.12s; + border-bottom: 1px solid var(--border-light); +} +.ws-picker-recent-item:last-child { border-bottom: none; } +.ws-picker-recent-item:hover { background: var(--bg-hover); } +.ws-picker-recent-item.active { + background: var(--accent-bg); + color: var(--accent-text); + font-weight: 500; +} + +.ws-picker-recent-icon { + width: 18px; + height: 18px; + flex-shrink: 0; + color: var(--text-tertiary); + opacity: 0.6; +} +.ws-picker-recent-item:hover .ws-picker-recent-icon, +.ws-picker-recent-item.active .ws-picker-recent-icon { + color: var(--accent); + opacity: 1; +} + +.ws-picker-recent-info { + flex: 1; + min-width: 0; + overflow: hidden; +} + +.ws-picker-recent-name { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + color: var(--text); +} + +.ws-picker-recent-path { + display: block; + font-size: 11px; + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 2px; +} +.ws-picker-recent-count { + font-size: 11px; + color: var(--text-tertiary); + background: var(--bg-elevated); + border: 1px solid var(--border-light); + border-radius: 10px; + padding: 2px 8px; + flex-shrink: 0; +} +.ws-picker-divider { + height: 1px; + background: var(--border-light); + margin: 10px 0; +} + +.ws-picker-actions-row { + display: flex; + gap: 8px; +} + +.ws-picker-actions-row .btn-workspace-action { + flex: 1; + justify-content: center; +} + background: var(--border); + margin: 6px 0; +} +.ws-picker-recent-loading, +.ws-picker-recent-empty { + padding: 16px; + text-align: center; + font-size: 12px; + color: var(--text-tertiary); +} + /* v0.5.at: chip-lan — 跟其他 btn-menu 同结构,仅 WiFi 图标按状态着色(文字 / 箭头保持正常色) */ .btn-lan-toggle .chip-lan-icon { transition: stroke 0.15s; @@ -3453,3 +3645,173 @@ button.chip-alerts .icon { width: 14px; height: 14px; display: block; } font-size: 11px; color: var(--text-tertiary); } + +/* ============================================================ + v1.2 (feat-workspace-lhl): 工作区零弹窗选择 + quick picker + ============================================================ */ + +/* Switch Workspace 弹层 */ +.workspace-picker-input { + width: 100%; + margin-top: 8px; + padding: 7px 10px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--bg-input); + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + box-sizing: border-box; +} + +.workspace-picker-actions-row { + margin-top: 10px; + display: flex; + gap: 8px; +} + +.workspace-picker-actions-row.split { + justify-content: space-between; +} + +.btn-workspace-action { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--bg-elevated); + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.btn-workspace-action:hover { + background: var(--bg-hover); + border-color: var(--border-strong); +} + +/* 零弹窗候选确认区 */ +.ws-native-candidates { + margin-top: 10px; + padding: 10px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--bg-elevated); + max-height: 220px; + overflow-y: auto; +} + +.ws-native-candidates-title { + font-size: 12px; + color: var(--text-tertiary); + margin-bottom: 8px; +} + +.ws-native-cand-btn { + display: flex; + flex-direction: column; + width: 100%; + padding: 8px 10px; + margin-bottom: 6px; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + background: var(--bg-base); + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + cursor: pointer; + text-align: left; + gap: 2px; + transition: background 0.15s, border-color 0.15s; +} + +.ws-native-cand-btn:last-child { margin-bottom: 0; } + +.ws-native-cand-btn:hover { + background: var(--bg-hover); + border-color: var(--border-strong); +} + +.ws-native-cand-name { + font-weight: 600; + font-size: 13px; +} + +.ws-native-cand-path { + font-size: 11px; + color: var(--text-tertiary); + word-break: break-all; +} + +/* Quick picker 下拉(welcome 页 chip 锚定) */ +#ws-quick-list { + position: absolute; + z-index: 1000; + min-width: 260px; + max-width: 380px; + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + padding: 6px 0; + overflow: hidden; +} + +.ws-quick-loading, +.ws-quick-empty { + padding: 16px; + text-align: center; + font-size: 13px; + color: var(--text-tertiary); +} + +.ws-quick-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 9px 14px; + cursor: pointer; + font-size: 13px; + color: var(--text-primary); + gap: 8px; + transition: background 0.12s; +} + +.ws-quick-item:hover { background: var(--bg-hover); } + +.ws-quick-item.active { + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + font-weight: 600; +} + +.ws-quick-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ws-quick-count { + font-size: 11px; + color: var(--text-tertiary); + background: var(--bg-base); + border: 1px solid var(--border-light); + border-radius: 10px; + padding: 1px 7px; + flex-shrink: 0; +} + +.ws-quick-add { + padding: 9px 14px; + cursor: pointer; + font-size: 13px; + color: var(--primary); + border-top: 1px solid var(--border-light); + margin-top: 4px; + transition: background 0.12s; +} + +.ws-quick-add:hover { background: color-mix(in srgb, var(--primary) 10%, transparent); } diff --git a/server/lib/fs-util.js b/server/lib/fs-util.js new file mode 100644 index 0000000..4f53326 --- /dev/null +++ b/server/lib/fs-util.js @@ -0,0 +1,101 @@ +// server/lib/fs-util.js — 文件系统工具类(feat-workspace-lhl) +// +// 提供目录浏览、条目详情、创建目录等功能。 +// 后续可用于 sidebar 文件树管理。 + +import { readdirSync, statSync, mkdirSync } from 'node:fs' +import { join, resolve, extname, basename } from 'node:path' +import { homedir } from 'node:os' + +// 权限字符串 +function modeToString(mode) { + const octal = (mode & 0o777).toString(8).padStart(3, '0') + return octal +} + +// 图标类型 +function getIconType(name, stat) { + if (stat.isDirectory()) return 'folder' + const ext = extname(name).toLowerCase() + const iconMap = { + '.js': 'file-code', '.mjs': 'file-code', '.cjs': 'file-code', + '.ts': 'file-code', '.tsx': 'file-code', '.jsx': 'file-code', + '.py': 'file-code', '.rs': 'file-code', '.go': 'file-code', + '.json': 'file-json', '.yaml': 'file-yaml', '.yml': 'file-yaml', + '.html': 'file-html', '.css': 'file-css', '.scss': 'file-css', + '.md': 'file-md', '.txt': 'file-text', '.log': 'file-text', + '.png': 'file-image', '.jpg': 'file-image', '.jpeg': 'file-image', + '.gif': 'file-image', '.svg': 'file-image', '.webp': 'file-image', + '.mp3': 'file-audio', '.wav': 'file-audio', '.ogg': 'file-audio', + '.mp4': 'file-video', '.mkv': 'file-video', '.avi': 'file-video', + '.pdf': 'file-pdf', + '.zip': 'file-archive', '.tar': 'file-archive', '.gz': 'file-archive', + '.exe': 'file-exe', '.sh': 'file-sh', + } + return iconMap[ext] || 'file' +} + +// 读取目录条目 +export function readDirectory(targetPath, opts = {}) { + const { limit = 500, showHidden = false } = opts + const absPath = resolve(targetPath) + + let parent = null + try { + parent = resolve(absPath, '..') + } catch {} + + let entries = [] + let skipped = 0 + try { + const items = readdirSync(absPath, { withFileTypes: true }) + for (const item of items) { + if (!showHidden && item.name.startsWith('.')) continue + if (entries.length >= limit) { skipped = items.length - entries.length; break } + + try { + const fullPath = join(absPath, item.name) + const stat = statSync(fullPath) + entries.push({ + name: item.name, + path: fullPath, + type: item.isDirectory() ? 'dir' : 'file', + size: stat.size, + mtime: stat.mtimeMs, + mode: modeToString(stat.mode), + icon: getIconType(item.name, stat), + // isSymlink 暂不暴露 + }) + } catch {} + } + } catch (e) { + return { ok: false, error: e.message, path: absPath } + } + + // 排序:目录优先,按名称 + entries.sort((a, b) => { + if (a.type !== b.type) return a.type === 'dir' ? -1 : 1 + return a.name.localeCompare(b.name) + }) + + return { + ok: true, + path: absPath, + parent, + home: homedir(), + entries, + skipped, + total: entries.length, + } +} + +// 创建目录 +export function createDirectory(targetPath) { + const absPath = resolve(targetPath) + try { + mkdirSync(absPath, { recursive: true }) + return { ok: true, path: absPath } + } catch (e) { + return { ok: false, error: e.message, path: absPath } + } +} diff --git a/server/lib/workspace.js b/server/lib/workspace.js index 8512213..ec8619e 100644 --- a/server/lib/workspace.js +++ b/server/lib/workspace.js @@ -8,11 +8,13 @@ import { mkdirSync, writeFileSync, } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { homedir, tmpdir } from "node:os"; +import { spawn } from "node:child_process"; import { DEFAULT_WORKSPACE } from "./config.js"; import { detectTuiCwd } from "./config.js"; import { pushStateFor } from "./state-bus.js"; +import { loadSessions } from "./sessions.js"; // v0.5.al: per-cid 切换 workspace // body: {dir, syncTui?, saveRecent?} @@ -72,8 +74,345 @@ export function handleWorkspaceChange(cs, cid, payload) { }; } +// v1.2 (feat-workspace-lhl): 展开 ~ 前缀 — 目录选择降级路径 / 手动输入都 +// 可以直接写 "~/projects/foo",服务端统一展开为主目录绝对路径。 +// 只处理开头恰好一个 ~(~ 自身、~/、~\ 三种形态);~user 语法不支持。 +export function expandTilde(rawPath) { + if (typeof rawPath !== "string") return rawPath; + const trimmed = rawPath.trim(); + if (trimmed === "~") return homedir(); + if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) { + return join(homedir(), trimmed.slice(2)); + } + return trimmed; +} + +// v1.2 (feat-workspace-lhl): 零弹窗目录选择配套 — 前端 webkitdirectory input +// (隐藏 file input,普通上传手势、无浏览器授权弹窗)只能拿到「文件夹名」, +// 绝对路径由服务端按名字在常见根目录里搜一遍,把候选交给用户确认 +// (唯一候选直接提交,多个候选内联点选,搜不到就降级内置目录树)。 +// 搜索根按平台区分: +// - 所有平台: home 自身 + home 一级子目录 + home 常见项目父目录的二级 +// - win32: 每个存在盘符的根(C:\name D:\name …) +// - darwin: /Volumes 挂载卷(/Volumes/name) +// - linux: /mnt、/media(/$USER)、/run/media(/$USER) +// opts.home / opts.platform / opts.user 可注入(测试用)。 +export function resolveWorkspaceCandidates(rawName, opts = {}) { + const home = opts.home || homedir(); + const platform = opts.platform || process.platform; + const name = typeof rawName === "string" ? rawName.trim() : ""; + // 校验:必须是纯目录名(不能带路径分隔符),防注入/防误用 + if ( + !name || + name === "." || + name === ".." || + name.includes("/") || + name.includes("\\") || + name.length > 255 + ) { + return { + ok: false, + error: "非法目录名(不能为空、不能包含路径分隔符)", + name: name || null, + home, + platform, + candidates: [], + }; + } + const candidates = []; + const seen = new Set(); + const push = (dirPath, via) => { + try { + const abs = resolve(dirPath); + if (seen.has(abs)) return; + if (!existsSync(abs) || !statSync(abs).isDirectory()) return; + seen.add(abs); + candidates.push({ path: abs, via }); + } catch {} + }; + // 跳过隐藏目录和已知巨无霸目录(只影响"往下再搜一层"的父目录遍历) + const SKIP_DIRS = new Set([ + "node_modules", + ".git", + "AppData", + "Application Data", + "Library", + "Windows", + "Program Files", + "Program Files (x86)", + "System Volume Information", + "$RECYCLE.BIN", + ]); + const safeReaddir = (dirPath) => { + try { + return readdirSync(dirPath, { withFileTypes: true }) + .filter( + (ent) => + ent.isDirectory() && + ent.name !== "." && + ent.name !== ".." && + !ent.name.startsWith(".") && + !SKIP_DIRS.has(ent.name), + ) + .slice(0, 500) // 与 browseWorkspace 同款上限,防 huge dirs + .map((ent) => ent.name); + } catch { + return []; + } + }; + const listChildren = (dirPath) => safeReaddir(dirPath).map((n) => join(dirPath, n)); + + // 1) home 自身(仅当其 basename 恰好等于目标名,例如 home 是 /Users/proj) + if (basename(home) === name) push(home, "home"); + // 2) home 一级子目录: ~/name(只收名字精确匹配的) + const homeChildren = listChildren(home); + for (const child of homeChildren) { + if (basename(child) === name) push(child, "home-sub"); + } + // 3) home 常见项目父目录的二级: ~/projects/name、~/Desktop/name … + const COMMON_PROJECT_PARENTS = new Set([ + "Desktop", "Documents", "Downloads", "projects", "Projects", "code", + "Code", "codes", "Codes", "dev", "Dev", "develop", "Develop", + "workspace", "workspaces", "repos", "repo", "Repo", "src", "git", + "work", "Work", "桌面", "文档", "下载", "项目", "代码", "工作区", + ]); + for (const child of homeChildren) { + if (!COMMON_PROJECT_PARENTS.has(basename(child))) continue; + for (const grand of listChildren(child)) { + if (basename(grand) === name) push(grand, "home-deep"); + } + } + // 4) 平台特有根 + if (platform === "win32") { + for (let c = 65; c <= 90; c++) { + const drive = String.fromCharCode(c) + ":\\"; + try { + if (!existsSync(drive)) continue; + } catch { + continue; + } + push(join(drive, name), "drive"); + for (const child of listChildren(drive)) push(join(child, name), "drive-sub"); + } + } else if (platform === "darwin") { + for (const vol of listChildren("/Volumes")) { + push(join(vol, name), "volumes"); + } + } else { + // linux: 外接盘 / 容器挂载点常见位置 + const user = opts.user || process.env.USER || process.env.USERNAME || ""; + const mediaRoots = ["/mnt", "/media", `/media/${user}`, "/run/media", `/run/media/${user}`]; + for (const root of mediaRoots) { + push(join(root, name), "mnt"); + for (const child of listChildren(root)) push(join(child, name), "mnt-sub"); + } + } + return { + ok: true, + name, + platform, + home, + candidates: candidates.slice(0, 12), // 超过 12 个候选基本等于没解析,让用户手动浏览 + }; +} + +// v2 (feat-workspace-lhl): GET /api/workspace/recent +// 从 sessions DB 模糊搜索工作区列表,按最近会话时间倒排。 +// search: 模糊匹配路径(不区分大小写),可空 +// limit: 最大返回条数(默认 5,后端固定上限 20) +// 响应: { ok, items: [{dir, name, lastActiveAt, sessionCount}], total } +export function getRecentWorkspaces({ search = "", limit = 5 } = {}) { + const all = loadSessions(); + // 按 dir 分组,聚合 lastActiveAt 和 sessionCount + const map = new Map(); + for (const s of all) { + const dir = (s.workspace || "").trim(); + if (!dir) continue; + const time = s.updatedAt || s.createdAt || 0; + if (!map.has(dir)) { + map.set(dir, { dir, lastActiveAt: time, sessionCount: 0 }); + } else { + const entry = map.get(dir); + entry.lastActiveAt = Math.max(entry.lastActiveAt, time); + } + map.get(dir).sessionCount++; + } + let items = [...map.values()]; + if (search && search.trim()) { + const q = search.trim().toLowerCase(); + items = items.filter( + (it) => + it.dir.toLowerCase().includes(q) || + basename(it.dir).toLowerCase().includes(q), + ); + } + items.sort((a, b) => b.lastActiveAt - a.lastActiveAt); + const MAX_LIMIT = 20; + const safeLimit = Math.min(Number(limit) || 5, MAX_LIMIT); + const sliced = items.slice(0, safeLimit); + return { + ok: true, + items: sliced.map((it) => ({ + dir: it.dir, + name: basename(it.dir) || it.dir, + lastActiveAt: it.lastActiveAt, + sessionCount: it.sessionCount, + })), + total: items.length, + search: search.trim(), + limit: safeLimit, + }; +} + +// v2 (feat-workspace-lhl): POST /api/workspace/pick +// 后端 spawn 原生 OS 目录选择器(和 dsh 相同方式), +// Linux: zenity → kdialog fallback;macOS: osascript;Windows: PowerShell dialog。 +// 返回 { ok, path }(用户取消时 path === null)。 +export function pickDirectoryNative(signal) { + const platform = process.platform; + return new Promise((resolve, reject) => { + let child; + let settled = false; + const settle = (fn) => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", onAbort); + child?.kill(); + fn(); + }; + const onAbort = () => { + settle(() => reject(new Error("picker aborted"))); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + try { + if (platform === "linux") { + // 优先用 kdialog(KDE 原生目录选择器,无"上传"按钮,体验最好) + // kdialog 也有 TTY 问题,用 setsid 创建独立 session + child = spawn("setsid", ["--", "kdialog", "--getexistingdirectory", ".", "--title", "选择工作区目录"], { + stdio: ["ignore", "pipe", "inherit"], + windowsHide: true, + env: { ...process.env }, + detached: false, + }); + let stdout = ""; + child.stdout.on("data", (d) => (stdout += d)); + child.on("close", (code) => { + if (signal?.aborted) { + settle(() => reject(new Error("picker aborted"))); + } else if (code === 0) { + const path = stdout.replace(/[\r\n]+$/, "").trim(); + settle(() => resolve(path || null)); + } else { + // 用户取消(code === 1)或其他错误 → try zenity + settle(() => { + tryZenity(signal).then(resolve).catch(reject); + }); + } + }); + child.on("error", (e) => { + settle(() => { + if (e.code === "ENOENT") { + tryZenity(signal).then(resolve).catch(reject); + } else { + reject(e); + } + }); + }); + } else if (platform === "darwin") { + child = spawn("osascript", [ + "-e", + 'set selectedFolder to choose folder with prompt "选择工作区目录"', + "-e", + "POSIX path of selectedFolder", + ], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: { ...process.env } }); + let stdout = ""; + child.stdout.on("data", (d) => (stdout += d)); + child.on("close", (code) => { + if (signal?.aborted) { + settle(() => reject(new Error("picker aborted"))); + } else if (code === 0) { + const path = stdout.replace(/[\r\n]+$/, "").trim(); + settle(() => resolve(path || null)); + } else { + // 用户取消(osascript -128 = user cancelled) + settle(() => resolve(null)); + } + }); + child.on("error", (e) => settle(() => reject(e))); + } else if (platform === "win32") { + // Windows: PowerShell 风格 folder picker(不依赖三方库) + const ps = [ + "Add-Type -AssemblyName System.Windows.Forms", + "$f = New-Object System.Windows.Forms.FolderBrowserDialog", + "$f.Description = '选择工作区目录'", + "$f.ShowNewFolderButton = $true", + "if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $f.SelectedPath } else { '' }", + ].join("; "); + child = spawn("powershell", ["-NoProfile", "-Command", ps], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + env: { ...process.env }, + }); + let stdout = ""; + child.stdout.on("data", (d) => (stdout += d)); + child.on("close", (code) => { + if (signal?.aborted) { + settle(() => reject(new Error("picker aborted"))); + } else if (code === 0) { + const path = stdout.replace(/[\r\n]+$/, "").trim(); + settle(() => resolve(path || null)); + } else { + settle(() => resolve(null)); + } + }); + child.on("error", (e) => settle(() => reject(e))); + } else { + settle(() => reject(new Error(`unsupported platform: ${platform}`))); + } + } catch (e) { + settle(() => reject(e)); + } + }); +} + +async function tryKdialog(signal) { + return new Promise((resolve, reject) => { + let settled = false; + const settle = (fn) => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", onAbort); + child?.kill(); + fn(); + }; + const onAbort = () => settle(() => reject(new Error("picker aborted"))); + signal?.addEventListener("abort", onAbort, { once: true }); + const child = spawn("setsid", ["--", "kdialog", "--getexistingdirectory", ".", "--title", "选择工作区目录"], { + stdio: ["ignore", "pipe", "inherit"], + windowsHide: true, + env: { ...process.env }, + }); + let stdout = ""; + child.stdout.on("data", (d) => (stdout += d)); + child.on("close", (code) => { + if (signal?.aborted) { + settle(() => reject(new Error("picker aborted"))); + } else if (code === 0) { + const path = stdout.replace(/[\r\n]+$/, "").trim(); + settle(() => resolve(path || null)); + } else { + settle(() => resolve(null)); // 用户取消 + } + }); + child.on("error", (e) => { + settle(() => reject(new Error("no supported native directory picker found (install zenity or kdialog)"))); + }); + }); +} + // v0.5.am: 列出目录下的子目录(仅目录,懒加载给前端树用) -// query: ?path= (省略时返回根盘符 / 根目录) +// query: ?path= 或 "~/xxx"(v1.2 起支持 ~ 展开) +// (省略时返回根盘符 / 根目录,响应同时带 home/platform/tmpDir 供前端定位) export function browseWorkspace(rawPath) { const MAX = 500; // 单层最多返回 500 个子目录,避免 huge dirs 把前端卡死 let target, @@ -97,7 +436,7 @@ export function browseWorkspace(rawPath) { target = "/"; } } else { - target = resolve(rawPath); + target = resolve(expandTilde(rawPath)); if (!existsSync(target) || !statSync(target).isDirectory()) { return { ok: false, error: `目录不存在: ${rawPath}` }; } @@ -136,8 +475,21 @@ export function browseWorkspace(rawPath) { children, skipped, total: dirs.length, + // v1.2: 前端降级树初始定位 / 「无需工作空间」按钮用 + home: homedir(), + tmpDir: tmpdir(), + platform: process.platform, }; } else { - return { ok: true, dir: null, parent: null, roots, children: [] }; + return { + ok: true, + dir: null, + parent: null, + roots, + children: [], + home: homedir(), + tmpDir: tmpdir(), + platform: process.platform, + }; } } diff --git a/server/router.js b/server/router.js index f344398..8fa2720 100644 --- a/server/router.js +++ b/server/router.js @@ -45,6 +45,8 @@ import * as settingsRoute from "./routes/settings.js"; import * as uploadRoute from "./routes/upload.js"; import * as modelRoute from "./routes/model.js"; import * as debugRoute from "./routes/debug.js"; +// v4.0 (feat-workspace-lhl): 原生风格目录选择器 — /api/fs/read 和 /api/fs/mkdir +import * as fsRoute from "./routes/fs.js"; // v0.5.by: mcode acp 协议 RPC 路由 (set_mode / set_config_option / cancel / load / activate) import * as protocolRoute from "./routes/protocol.js"; @@ -234,6 +236,40 @@ const ROUTES = [ match: (p) => p === "/api/workspace/browse", handler: workspaceRoute.handleWorkspaceBrowse, }, + // v1.2 (feat-workspace-lhl): 工作区→会话树 + 文件夹名解析候选 + { + method: "GET", + match: (p) => p === "/api/workspace/tree", + handler: workspaceRoute.handleWorkspaceTree, + }, + { + method: "GET", + match: (p) => p === "/api/workspace/resolve", + handler: workspaceRoute.handleWorkspaceResolve, + }, + // v2 (feat-workspace-lhl): recent list + native OS picker + { + method: "GET", + match: (p) => p === "/api/workspace/recent", + handler: workspaceRoute.handleWorkspaceRecent, + }, + { + method: "POST", + match: (p) => p === "/api/workspace/pick", + handler: workspaceRoute.handleWorkspacePick, + }, + + // v4.0 (feat-workspace-lhl): 文件系统 API(原生风格目录选择器) + { + method: "GET", + match: (p) => p === "/api/fs/read", + handler: fsRoute.handleFsRead, + }, + { + method: "POST", + match: (p) => p === "/api/fs/mkdir", + handler: fsRoute.handleFsMkdir, + }, // Settings { diff --git a/server/routes/fs.js b/server/routes/fs.js new file mode 100644 index 0000000..12872af --- /dev/null +++ b/server/routes/fs.js @@ -0,0 +1,54 @@ +// server/routes/fs.js — 文件系统 API(feat-workspace-lhl) +// +// GET /api/fs/read?path=xxx&showHidden=0 读取目录 +// POST /api/fs/mkdir 创建目录 { path } + +import { readDirectory, createDirectory } from '../lib/fs-util.js' + +// 安全检查:禁止访问 public 目录以外、/proc /sys 等系统路径 +function safePath(rawPath) { + if (!rawPath || typeof rawPath !== 'string') return null + // 禁止绝对路径跳出 home + if (rawPath.includes('..')) return null + return rawPath +} + +export function handleFsRead(req, res) { + const url = new URL(req.url, `http://localhost`) + let rawPath = url.searchParams.get('path') || '' + const showHidden = url.searchParams.get('showHidden') === '1' + + if (!rawPath) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ ok: false, error: 'missing path' })) + return + } + + const result = readDirectory(rawPath, { showHidden }) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(result)) +} + +export function handleFsMkdir(req, res) { + let body = '' + req.on('data', (chunk) => { body += chunk }) + req.on('end', () => { + let data + try { data = JSON.parse(body) } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ ok: false, error: 'invalid json' })) + return + } + + const path = safePath(data.path) + if (!path) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ ok: false, error: 'invalid path' })) + return + } + + const result = createDirectory(path) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(result)) + }) +} diff --git a/server/routes/workspace.js b/server/routes/workspace.js index f22cef6..3074a47 100644 --- a/server/routes/workspace.js +++ b/server/routes/workspace.js @@ -1,7 +1,19 @@ // webui/server/routes/workspace.js // POST /api/workspace, GET /api/workspace/browse +// v1.2 (feat-workspace-lhl): + GET /api/workspace/tree(工作区→会话树,sessions +// store 分组)+ GET /api/workspace/resolve(文件夹名 → 绝对路径候选) -import { handleWorkspaceChange, browseWorkspace } from "../lib/workspace.js"; +import { basename } from "node:path"; +import { homedir, tmpdir } from "node:os"; +import { + handleWorkspaceChange, + browseWorkspace, + resolveWorkspaceCandidates, + getRecentWorkspaces, + pickDirectoryNative, +} from "../lib/workspace.js"; +import { DEFAULT_WORKSPACE } from "../lib/config.js"; +import { loadSessions } from "../lib/sessions.js"; export async function handleWorkspace(req, res, ctx) { let body = ""; @@ -33,3 +45,108 @@ export function handleWorkspaceBrowse(req, res, _ctx) { }); return res.end(JSON.stringify(result)); } + +// v1.2 (feat-workspace-lhl): GET /api/workspace/tree +// 工作区→会话树形结构(webui sessions store 按 workspace 分组)。 +// chip 锚定下拉 / Switch Workspace 弹层的工作区下拉都用它。 +// 响应: { ok, current, defaultWorkspace, home, tmpDir, platform, +// workspaces: [{ dir, name, sessionCount, lastActiveAt, current, sessions[] }] } +export function handleWorkspaceTree(req, res, ctx) { + const current = (ctx.cs && ctx.cs.workspace && ctx.cs.workspace.dir) || ""; + const all = loadSessions(); + const groups = new Map(); + for (const s of all) { + const ws = (s.workspace || "").trim(); + if (!ws) continue; // 无工作区的旧 session 不分组 + if (!groups.has(ws)) groups.set(ws, []); + groups.get(ws).push({ + id: s.id, + mcodeSessionId: s.mcodeSessionId || null, + title: s.title || "Untitled", + updatedAt: s.updatedAt || s.createdAt || 0, + }); + } + const workspaces = [...groups.entries()].map(([dir, sessions]) => { + sessions.sort((a, b) => b.updatedAt - a.updatedAt); + return { + dir, + name: basename(dir) || dir, + sessionCount: sessions.length, + lastActiveAt: sessions[0] ? sessions[0].updatedAt : 0, + current: dir === current, + sessions, + }; + }); + // 当前工作区即使还没有任何 session 也出现在树顶(新建会话最常选它) + if (current && !groups.has(current)) { + workspaces.unshift({ + dir: current, + name: basename(current) || current, + sessionCount: 0, + lastActiveAt: 0, + current: true, + sessions: [], + }); + } + workspaces.sort((a, b) => { + if (a.current !== b.current) return a.current ? -1 : 1; + return b.lastActiveAt - a.lastActiveAt; + }); + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + return res.end( + JSON.stringify({ + ok: true, + current, + defaultWorkspace: DEFAULT_WORKSPACE, + home: homedir(), + // 「无需工作空间」按钮用: linux /tmp, macOS /var/folders/..., win32 %TEMP% + tmpDir: tmpdir(), + platform: process.platform, + workspaces, + }), + ); +} + +// v1.2 (feat-workspace-lhl): GET /api/workspace/resolve?name= +// 零弹窗目录选择(webkitdirectory input)只给前端返回文件夹名,不出绝对路径。 +// server 与浏览器同机,这里按名字在常见根目录里搜候选绝对路径,交给用户确认。 +export function handleWorkspaceResolve(req, res, _ctx) { + const url = new URL(req.url, `http://${req.headers.host}`); + const name = url.searchParams.get("name") || ""; + const result = resolveWorkspaceCandidates(name); + res.writeHead(result.ok ? 200 : 400, { + "Content-Type": "application/json; charset=utf-8", + }); + return res.end(JSON.stringify(result)); +} + +// v2 (feat-workspace-lhl): GET /api/workspace/recent +// 返回最近工作区列表(后端 DB 模糊搜索)。 +// ?search= 模糊匹配路径(可空);?limit= 最大条数(默认 5,上限 20) +// 响应: { ok, items: [{dir, name, lastActiveAt, sessionCount}], total, search, limit, tmpDir } +export function handleWorkspaceRecent(req, res, _ctx) { + const url = new URL(req.url, `http://${req.headers.host}`); + const search = url.searchParams.get("search") || ""; + const limit = Number(url.searchParams.get("limit")) || 5; + const result = getRecentWorkspaces({ search, limit }); + // 添加 tmpDir,供「无需工作空间」按钮使用 + result.tmpDir = tmpdir(); + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + return res.end(JSON.stringify(result)); +} + +// v2 (feat-workspace-lhl): POST /api/workspace/pick +// 后端 spawn 原生 OS 目录选择器(zenity/kdialog/osascript/PowerShell)。 +// 响应: { ok, path }(用户取消时 path === null) +export async function handleWorkspacePick(req, res, _ctx) { + try { + const path = await pickDirectoryNative(); + const result = { ok: true, path }; + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + return res.end(JSON.stringify(result)); + } catch (e) { + const result = { ok: false, error: e.message }; + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + return res.end(JSON.stringify(result)); + } +}