1// static/js/workspace.js 2// 3// Workspace picker: browse server directories in a draggable modal, choose a 4// folder, and show it as a removable pill in the chat input bar. While set, the 5// chat request sends `workspace` so the agent's file/shell tools are confined 6// to that folder (see routes/chat_routes.py + src/tool_execution.py). 7 8import Storage, { KEYS } from './storage.js'; 9import uiModule from './ui.js'; 10import { makeWindowDraggable } from './windowDrag.js'; 11 12const API_BASE = window.location.origin; 13// Same folder glyph as the overflow menu item + pill (not an emoji). 14const _FOLDER_SVG = '<svg class="workspace-row-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>'; 15let _modal = null; 16let _curPath = ''; 17 18export function getWorkspace() { 19 return Storage.get(KEYS.WORKSPACE, '') || ''; 20} 21 22function _basename(p) { 23 if (!p) return ''; 24 // Handle both POSIX (/) and Windows (\) separators. 25 const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/); 26 return parts[parts.length - 1] || p; 27} 28 29export function syncWorkspaceIndicator(path) { 30 const pill = document.getElementById('workspace-indicator-btn'); 31 const name = document.getElementById('workspace-indicator-name'); 32 const overflow = document.getElementById('overflow-workspace-btn'); 33 if (pill) { 34 pill.style.display = path ? '' : 'none'; 35 pill.classList.toggle('active', !!path); 36 if (path) pill.title = `Workspace: ${path} â click to clear`; 37 } 38 if (name) name.textContent = path ? _basename(path) : ''; 39 if (overflow) overflow.classList.toggle('active', !!path); 40 // Recompute the "+" overflow dot (app.js owns updatePlusDot via this event). 41 try { document.dispatchEvent(new CustomEvent('overflow-state-change')); } catch (_) {} 42} 43 44export function setWorkspace(path) { 45 if (path) Storage.set(KEYS.WORKSPACE, path); 46 else Storage.remove(KEYS.WORKSPACE); 47 syncWorkspaceIndicator(path || ''); 48} 49 50export function clearWorkspace() { 51 setWorkspace(''); 52 if (uiModule && uiModule.showToast) uiModule.showToast('Workspace cleared'); 53} 54 55async function _load(path) { 56 const url = `${API_BASE}/api/workspace/browse${path ? `?path=${encodeURIComponent(path)}` : ''}`; 57 const res = await fetch(url, { credentials: 'same-origin' }); 58 if (!res.ok) throw new Error(`browse failed: ${res.status}`); 59 return res.json(); 60} 61 62function _render(data) { 63 _curPath = data.path; 64 const body = _modal.querySelector('#workspace-body'); 65 const pathEl = _modal.querySelector('#workspace-cur-path'); 66 if (pathEl) { 67 // Reflect the resolved (realpath) location back into the editable field. 68 pathEl.value = data.path; 69 pathEl.title = data.path; 70 } 71 let rows = ''; 72 if (data.parent) { 73 rows += `<div class="workspace-row workspace-up" data-path="${encodeURIComponent(data.parent)}">â ..</div>`; 74 } 75 for (const d of data.dirs) { 76 // Backend supplies the full child path (os.path.join â cross-platform). 77 rows += `<div class="workspace-row" data-path="${encodeURIComponent(d.path)}">${_FOLDER_SVG}<span>${uiModule.esc(d.name)}</span></div>`; 78 } 79 if (!data.dirs.length && !data.parent) rows = '<div class="workspace-empty">No subfolders</div>'; 80 body.innerHTML = rows || '<div class="workspace-empty">No subfolders</div>'; 81 body.querySelectorAll('.workspace-row').forEach((row) => { 82 row.addEventListener('click', () => _navigate(decodeURIComponent(row.dataset.path))); 83 }); 84} 85 86async function _navigate(path) { 87 try { 88 _render(await _load(path)); 89 } catch (e) { 90 if (uiModule && uiModule.showError) uiModule.showError('Could not open folder'); 91 } 92} 93 94function _getModal() { 95 if (_modal) return _modal; 96 _modal = document.createElement('div'); 97 _modal.id = 'workspace-modal'; 98 _modal.className = 'modal'; 99 _modal.style.display = 'none'; 100 _modal.innerHTML = ` 101 <div class="modal-content"> 102 <div class="modal-header"> 103 <h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>Select workspace</h4> 104 <button class="close-btn" id="workspace-close" aria-label="Close">â</button> 105 </div> 106 <input type="text" class="styled-prompt-input workspace-cur" id="workspace-cur-path" 107 spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off" 108 placeholder="Type or paste a folder path, then press Enter" /> 109 <div class="modal-body workspace-body" id="workspace-body"></div> 110 <div class="modal-footer workspace-footer"> 111 <button type="button" class="confirm-btn confirm-btn-secondary" id="workspace-cancel">Cancel</button> 112 <button type="button" class="confirm-btn confirm-btn-primary" id="workspace-use">Use this folder</button> 113 </div> 114 </div>`; 115 document.body.appendChild(_modal); 116 _modal.querySelector('#workspace-close').addEventListener('click', closeWorkspaceBrowser); 117 _modal.querySelector('#workspace-cancel').addEventListener('click', closeWorkspaceBrowser); 118 // Editable path bar: Enter navigates to a typed/pasted folder. 119 _modal.querySelector('#workspace-cur-path').addEventListener('keydown', (e) => { 120 if (e.key === 'Enter') { 121 e.preventDefault(); 122 const v = e.target.value.trim(); 123 if (v) _navigate(v); 124 } 125 }); 126 _modal.querySelector('#workspace-use').addEventListener('click', () => { 127 setWorkspace(_curPath);
128 if (uiModule && uiModule.showToast) uiModule.showToast(`Workspace set: ${_basename(_curPath)}`); 129 closeWorkspaceBrowser(); 130 }); 131 const content = _modal.querySelector('.modal-content'); 132 const header = _modal.querySelector('.modal-header'); 133 if (content && header) makeWindowDraggable(_modal, { content, header }); 134 return _modal; 135} 136 137export async function openWorkspaceBrowser() { 138 const modal = _getModal(); 139 modal.style.display = 'flex'; 140 try { 141 _render(await _load(getWorkspace() || '')); 142 } catch (e) { 143 if (uiModule && uiModule.showError) uiModule.showError('Could not browse folders'); 144 } 145} 146 147export function closeWorkspaceBrowser() { 148 if (_modal) _modal.style.display = 'none'; 149} 150 151export function initWorkspace() { 152 // Restore persisted workspace into the pill on load. 153 syncWorkspaceIndicator(getWorkspace()); 154 const overflow = document.getElementById('overflow-workspace-btn'); 155 if (overflow) overflow.addEventListener('click', openWorkspaceBrowser); 156 const pill = document.getElementById('workspace-indicator-btn'); 157 if (pill) pill.addEventListener('click', clearWorkspace); 158} 159 160export default { initWorkspace, openWorkspaceBrowser, getWorkspace, setWorkspace, clearWorkspace, syncWorkspaceIndicator };
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.