// static/js/workspace.js // // Workspace picker: browse server directories in a draggable modal, choose a // folder, and show it as a removable pill in the chat input bar. While set, the // chat request sends `workspace` so the agent's file/shell tools are confined // to that folder (see routes/chat_routes.py + src/tool_execution.py). import Storage, { KEYS } from './storage.js'; import uiModule from './ui.js'; import { makeWindowDraggable } from './windowDrag.js'; const API_BASE = window.location.origin; // Same folder glyph as the overflow menu item + pill (not an emoji). const _FOLDER_SVG = ''; let _modal = null; let _curPath = ''; export function getWorkspace() { return Storage.get(KEYS.WORKSPACE, '') || ''; } function _basename(p) { if (!p) return ''; // Handle both POSIX (/) and Windows (\) separators. const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/); return parts[parts.length - 1] || p; } // Workspace only applies to agent mode (it scopes the file/shell tools), so the // pill + overflow entry are hidden in chat mode, like the bash toggle. function _isChatMode() { const b = document.getElementById('mode-chat-btn'); return !!(b && b.classList.contains('active')); } export function syncWorkspaceIndicator(path) { const chat = _isChatMode(); const pill = document.getElementById('workspace-indicator-btn'); const name = document.getElementById('workspace-indicator-name'); const overflow = document.getElementById('overflow-workspace-btn'); if (pill) { pill.style.display = (path && !chat) ? '' : 'none'; pill.classList.toggle('active', !!path); if (path) pill.title = `Workspace: ${path}\nFile tools are confined here; shell commands start here but are not sandboxed and can reach outside it.\nClick to clear.`; } if (name) name.textContent = path ? _basename(path) : ''; if (overflow) { overflow.style.display = chat ? 'none' : ''; overflow.classList.toggle('active', !!path); } // Recompute the "+" overflow dot (app.js owns updatePlusDot via this event). try { document.dispatchEvent(new CustomEvent('overflow-state-change')); } catch (_) {} } // Called by the agent/chat mode toggle so the pill + overflow entry follow mode. export function applyMode(_mode) { syncWorkspaceIndicator(getWorkspace()); } export function setWorkspace(path) { if (path) Storage.set(KEYS.WORKSPACE, path); else Storage.remove(KEYS.WORKSPACE); syncWorkspaceIndicator(path || ''); } /** * Validate a manually entered path server-side, then persist the canonical * form. Returns {ok, path|null}. Without this, a typo / file path / deleted * folder / filesystem root would be stored and shown as active while the * backend silently refuses to bind it on every send. */ export async function vetAndSetWorkspace(path) { try { const res = await fetch(`${API_BASE}/api/workspace/vet?path=${encodeURIComponent(path)}`, { credentials: 'same-origin' }); if (!res.ok) return { ok: false, path: null }; const data = await res.json(); if (data.ok && data.path) { setWorkspace(data.path); return { ok: true, path: data.path }; } return { ok: false, path: null }; } catch (e) { return { ok: false, path: null }; } } export function clearWorkspace() { setWorkspace(''); if (uiModule && uiModule.showToast) uiModule.showToast('Workspace cleared'); } async function _load(path) { const url = `${API_BASE}/api/workspace/browse${path ? `?path=${encodeURIComponent(path)}` : ''}`; const res = await fetch(url, { credentials: 'same-origin' }); if (!res.ok) throw new Error(`browse failed: ${res.status}`); return res.json(); } function _render(data) { _curPath = data.path; const body = _modal.querySelector('#workspace-body'); const pathEl = _modal.querySelector('#workspace-cur-path'); if (pathEl) { // Reflect the resolved (realpath) location back into the editable field. pathEl.value = data.path; pathEl.title = data.path; } let rows = ''; if (data.parent) { rows += `