Changeset 0.21.5 (#91)
This commit is contained in:
229
src/css/editor-mode.css
Normal file
229
src/css/editor-mode.css
Normal file
@@ -0,0 +1,229 @@
|
||||
/* ==========================================
|
||||
Editor Surface (v0.21.5)
|
||||
IDE-like layout: file tree | code + chat
|
||||
========================================== */
|
||||
|
||||
/* ── Header ──────────────────────────────── */
|
||||
|
||||
.editor-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 6px 12px; gap: 8px;
|
||||
font-size: 13px; color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 36px; box-sizing: border-box;
|
||||
}
|
||||
.editor-header-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.editor-ws-name { font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.editor-branch { font-size: 12px; color: var(--text-tertiary); white-space: nowrap; }
|
||||
.editor-header-right { display: flex; align-items: center; gap: 4px; }
|
||||
.editor-hdr-btn {
|
||||
background: none; border: none; color: var(--text-secondary);
|
||||
cursor: pointer; padding: 4px; border-radius: 4px;
|
||||
display: flex; align-items: center;
|
||||
}
|
||||
.editor-hdr-btn:hover { background: var(--hover); color: var(--text-primary); }
|
||||
|
||||
/* ── File Tree (sidebar-content region) ──── */
|
||||
|
||||
.editor-file-tree {
|
||||
flex: 1; overflow-y: auto; overflow-x: hidden;
|
||||
padding: 4px 0; font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
.editor-tree-row {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 3px 8px; cursor: pointer;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
border-radius: 4px; margin: 0 4px;
|
||||
}
|
||||
.editor-tree-row:hover { background: var(--hover); }
|
||||
.editor-tree-row.active { background: var(--active-bg, rgba(59,130,246,0.12)); color: var(--accent); }
|
||||
.editor-tree-arrow { width: 14px; text-align: center; font-size: 10px; color: var(--text-tertiary); flex-shrink: 0; }
|
||||
.editor-tree-arrow.expanded { /* already rotated via character */ }
|
||||
.editor-tree-icon { flex-shrink: 0; font-size: 14px; line-height: 1; }
|
||||
.editor-tree-name { overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.editor-tree-loading, .editor-tree-empty, .editor-tree-error {
|
||||
padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Main Split Layout ───────────────────── */
|
||||
|
||||
.editor-main {
|
||||
display: flex; flex: 1; min-height: 0; overflow: hidden;
|
||||
}
|
||||
.editor-left-pane {
|
||||
display: flex; flex-direction: column; min-width: 200px; overflow: hidden;
|
||||
}
|
||||
.editor-right-pane {
|
||||
display: flex; flex-direction: column; min-width: 200px; overflow: hidden;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Resize handle */
|
||||
.editor-split-handle {
|
||||
width: 5px; cursor: col-resize; flex-shrink: 0;
|
||||
background: transparent; position: relative; z-index: 5;
|
||||
}
|
||||
.editor-split-handle:hover,
|
||||
.editor-split-handle:active {
|
||||
background: var(--accent);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* ── Tab Bar ─────────────────────────────── */
|
||||
|
||||
.editor-tab-bar {
|
||||
display: flex; overflow-x: auto; overflow-y: hidden;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 32px; flex-shrink: 0;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.editor-tab-bar::-webkit-scrollbar { height: 0; }
|
||||
|
||||
.editor-tab {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 4px 10px; font-size: 12px;
|
||||
cursor: pointer; white-space: nowrap;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0; max-width: 180px;
|
||||
position: relative;
|
||||
}
|
||||
.editor-tab:hover { background: var(--hover); }
|
||||
.editor-tab.active {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
.editor-tab.modified .editor-tab-name { font-style: italic; }
|
||||
.editor-tab-icon { font-size: 13px; flex-shrink: 0; }
|
||||
.editor-tab-name { overflow: hidden; text-overflow: ellipsis; }
|
||||
.editor-tab-modified { color: var(--accent); font-size: 10px; margin-left: 2px; }
|
||||
.editor-tab-close {
|
||||
background: none; border: none; color: var(--text-tertiary);
|
||||
cursor: pointer; font-size: 11px; padding: 0 2px;
|
||||
border-radius: 3px; opacity: 0; transition: opacity 0.1s;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.editor-tab:hover .editor-tab-close { opacity: 1; }
|
||||
.editor-tab-close:hover { background: var(--hover); color: var(--text-primary); }
|
||||
|
||||
/* ── Code Editor Area ────────────────────── */
|
||||
|
||||
.editor-code-area {
|
||||
flex: 1; min-height: 0; position: relative; overflow: hidden;
|
||||
}
|
||||
.editor-cm-wrap {
|
||||
position: absolute; inset: 0; overflow: hidden;
|
||||
}
|
||||
.editor-cm-wrap .cm-editor { height: 100%; }
|
||||
.editor-cm-wrap .cm-scroller { overflow: auto; }
|
||||
|
||||
/* Textarea fallback */
|
||||
.editor-textarea-fallback {
|
||||
width: 100%; height: 100%; resize: none; border: none; outline: none;
|
||||
background: var(--bg-primary); color: var(--text-primary);
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
font-size: 13px; line-height: 1.5;
|
||||
padding: 8px 12px; box-sizing: border-box;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
/* Welcome screen */
|
||||
.editor-welcome {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.editor-welcome-inner { text-align: center; color: var(--text-tertiary); }
|
||||
.editor-welcome-inner p { margin: 8px 0; font-size: 14px; }
|
||||
.editor-welcome-hint { font-size: 12px; opacity: 0.6; }
|
||||
.editor-welcome-hint kbd {
|
||||
background: var(--hover); border: 1px solid var(--border);
|
||||
border-radius: 3px; padding: 1px 5px; font-size: 11px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ── Chat Panel (right pane) ─────────────── */
|
||||
|
||||
.editor-chat-header {
|
||||
padding: 6px 12px; font-size: 12px; font-weight: 600;
|
||||
color: var(--text-secondary); border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.editor-chat-messages {
|
||||
flex: 1; overflow-y: auto; min-height: 0;
|
||||
}
|
||||
/* Inherit chat message styles when embedded */
|
||||
.editor-chat-messages .messages { height: auto; overflow: visible; }
|
||||
.editor-chat-input { flex-shrink: 0; }
|
||||
/* Inherit input-area styles */
|
||||
.editor-chat-input .input-area { border-top: 1px solid var(--border); }
|
||||
|
||||
/* ── Status Bar (footer) ─────────────────── */
|
||||
|
||||
.editor-status-bar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 2px 12px; font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
border-top: 1px solid var(--border);
|
||||
min-height: 22px; flex-shrink: 0;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.editor-status-sep { opacity: 0.3; }
|
||||
.editor-status-right { margin-left: auto; }
|
||||
.editor-status-branch { font-size: 11px; }
|
||||
.editor-status-file { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ── Context Menu ────────────────────────── */
|
||||
|
||||
.editor-ctx-menu {
|
||||
position: fixed; z-index: 1000;
|
||||
background: var(--bg-primary); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 4px 0;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
||||
min-width: 120px; font-size: 13px;
|
||||
}
|
||||
.editor-ctx-item {
|
||||
padding: 6px 12px; cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.editor-ctx-item:hover { background: var(--hover); }
|
||||
|
||||
/* ── Quick Open Overlay ──────────────────── */
|
||||
|
||||
.editor-quick-open {
|
||||
position: fixed; inset: 0; z-index: 2000;
|
||||
background: rgba(0,0,0,0.4);
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
padding-top: 15vh;
|
||||
}
|
||||
.editor-qo-dialog {
|
||||
background: var(--bg-primary); border: 1px solid var(--border);
|
||||
border-radius: 8px; width: 460px; max-width: 90vw;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor-qo-input {
|
||||
width: 100%; padding: 10px 14px; font-size: 14px;
|
||||
border: none; outline: none; box-sizing: border-box;
|
||||
background: var(--bg-primary); color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.editor-qo-results { max-height: 300px; overflow-y: auto; }
|
||||
.editor-qo-row {
|
||||
padding: 6px 14px; cursor: pointer; font-size: 13px;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.editor-qo-row:hover { background: var(--hover); }
|
||||
.editor-qo-icon { font-size: 14px; flex-shrink: 0; }
|
||||
|
||||
/* ── Mobile: single pane ─────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.editor-main { flex-direction: column; }
|
||||
.editor-left-pane { flex: 1 !important; min-width: 0; }
|
||||
.editor-right-pane { min-width: 0; border-left: none; border-top: 1px solid var(--border); max-height: 40vh; }
|
||||
.editor-split-handle { display: none; }
|
||||
}
|
||||
@@ -2583,6 +2583,8 @@ select option { background: var(--bg-surface); color: var(--text); }
|
||||
}
|
||||
.project-panel-select:focus { outline: none; border-color: var(--accent); }
|
||||
.project-panel-hint { font-size: 11px; color: var(--text-3); margin-top: 2px; }
|
||||
.project-panel-ws-row { display: flex; gap: 6px; align-items: center; }
|
||||
.project-panel-ws-row select { flex: 1; }
|
||||
.project-panel-checkbox {
|
||||
display: flex; align-items: center; gap: 8px; font-size: 13px;
|
||||
cursor: pointer; color: var(--text-2);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="stylesheet" href="css/styles.css?v=%%APP_VERSION%%">
|
||||
<link rel="stylesheet" href="css/editor-mode.css?v=%%APP_VERSION%%">
|
||||
<link rel="stylesheet" href="css/persona-kb.css?v=%%APP_VERSION%%">
|
||||
<link rel="stylesheet" href="css/memory.css?v=%%APP_VERSION%%">
|
||||
<link rel="stylesheet" href="css/notifications.css?v=%%APP_VERSION%%">
|
||||
@@ -1201,6 +1202,7 @@
|
||||
<script src="js/chat.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/editor-mode.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/app.js?v=%%APP_VERSION%%"></script>
|
||||
<script>
|
||||
// ── Service Worker Registration ─────────
|
||||
|
||||
@@ -207,6 +207,43 @@ const API = {
|
||||
},
|
||||
listProjectNotes(projectId) { return this._get(`/api/v1/projects/${projectId}/notes`); },
|
||||
|
||||
// ── Workspaces (v0.21.0) ────────────────
|
||||
getWorkspace(id) { return this._get(`/api/v1/workspaces/${id}`); },
|
||||
listWorkspaceFiles(wsId, path = '', recursive = false) {
|
||||
let url = `/api/v1/workspaces/${wsId}/files?path=${encodeURIComponent(path)}`;
|
||||
if (recursive) url += '&recursive=true';
|
||||
return this._get(url);
|
||||
},
|
||||
async readWorkspaceFile(wsId, path) {
|
||||
// Returns raw text, not JSON
|
||||
const resp = await fetch(BASE + `/api/v1/workspaces/${wsId}/files/read?path=${encodeURIComponent(path)}`, {
|
||||
headers: this._authHeaders(),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
throw Object.assign(new Error(data.error || `HTTP ${resp.status}`), { status: resp.status });
|
||||
}
|
||||
const ct = resp.headers.get('content-type') || '';
|
||||
if (ct.includes('application/json')) {
|
||||
const data = await resp.json();
|
||||
return data.content || '';
|
||||
}
|
||||
return resp.text();
|
||||
},
|
||||
writeWorkspaceFile(wsId, path, content) {
|
||||
return this._put(`/api/v1/workspaces/${wsId}/files/write`, { path, content });
|
||||
},
|
||||
deleteWorkspaceFile(wsId, path) {
|
||||
return this._del(`/api/v1/workspaces/${wsId}/files/delete?path=${encodeURIComponent(path)}`);
|
||||
},
|
||||
mkdirWorkspace(wsId, path) {
|
||||
return this._post(`/api/v1/workspaces/${wsId}/files/mkdir`, { path });
|
||||
},
|
||||
getWorkspaceGitStatus(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/status`); },
|
||||
getWorkspaceGitBranches(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/branches`); },
|
||||
listWorkspaces() { return this._get('/api/v1/workspaces'); },
|
||||
createWorkspace(data) { return this._post('/api/v1/workspaces', data); },
|
||||
|
||||
// ── Messages ─────────────────────────────
|
||||
|
||||
listMessages(channelId, page = 1, perPage = 200) {
|
||||
|
||||
936
src/js/editor-mode.js
Normal file
936
src/js/editor-mode.js
Normal file
@@ -0,0 +1,936 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Editor Surface (v0.21.5)
|
||||
// ==========================================
|
||||
// IDE-like experience built as a surface consuming workspace
|
||||
// primitives (v0.21.0) and surface infrastructure (v0.21.3).
|
||||
//
|
||||
// Layout: file tree (sidebar) | code editor (CM6) + chat panel (split)
|
||||
// Load order: surfaces.js → editor-mode.js (after app init)
|
||||
// ==========================================
|
||||
|
||||
const EditorMode = {
|
||||
|
||||
// ── State ────────────────────────────────
|
||||
_wsId: null, // workspace ID
|
||||
_wsName: '', // workspace display name
|
||||
_gitBranch: null, // current git branch (nullable)
|
||||
_registered: false, // surface registered?
|
||||
_active: false, // currently active?
|
||||
|
||||
// DOM (built once, reused across activations)
|
||||
_els: null, // { header, fileTree, main, footer }
|
||||
_built: false,
|
||||
|
||||
// File tree
|
||||
_treeData: [], // flat file list from API
|
||||
_expandedDirs: new Set(['']), // expanded directory paths
|
||||
|
||||
// Tabs + editors
|
||||
_openFiles: new Map(), // path → { tab, editorWrap, editor(CM6), content, modified, language }
|
||||
_activeFile: null, // path of currently active tab
|
||||
_tabBarEl: null,
|
||||
_editorAreaEl: null,
|
||||
_welcomeEl: null,
|
||||
|
||||
// Split
|
||||
_splitRatio: 0.6, // editor pane width ratio (0.0-1.0)
|
||||
|
||||
// ── Initialization ───────────────────────
|
||||
|
||||
/**
|
||||
* Check if the current channel/project has a workspace.
|
||||
* Called on channel switch and app init.
|
||||
*/
|
||||
async check() {
|
||||
const channelId = App.currentChatId;
|
||||
if (!channelId) {
|
||||
this._unregister();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ch = await API.getChannel(channelId);
|
||||
const wsId = ch.workspace_id || null;
|
||||
|
||||
// Also check project workspace if channel doesn't have one
|
||||
if (!wsId && ch.project_id) {
|
||||
try {
|
||||
const proj = await API.getProject(ch.project_id);
|
||||
if (proj.workspace_id) {
|
||||
this._register(proj.workspace_id, proj.name || 'Workspace');
|
||||
return;
|
||||
}
|
||||
} catch (_) { /* no project workspace */ }
|
||||
}
|
||||
|
||||
if (wsId) {
|
||||
// Fetch workspace details
|
||||
try {
|
||||
const ws = await API.getWorkspace(wsId);
|
||||
this._register(wsId, ws.name || 'Workspace');
|
||||
} catch (_) {
|
||||
this._register(wsId, 'Workspace');
|
||||
}
|
||||
} else {
|
||||
this._unregister();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[EditorMode] check failed:', e);
|
||||
}
|
||||
},
|
||||
|
||||
_register(wsId, name) {
|
||||
this._wsId = wsId;
|
||||
this._wsName = name;
|
||||
|
||||
if (this._registered) return;
|
||||
this._registered = true;
|
||||
|
||||
Surfaces.register('editor', {
|
||||
label: 'Editor',
|
||||
icon: 'code',
|
||||
regions: ['surface-header', 'surface-main', 'surface-footer', 'sidebar-content'],
|
||||
activate: () => this._activate(),
|
||||
deactivate: () => this._deactivate(),
|
||||
});
|
||||
|
||||
console.log(`[EditorMode] Registered for workspace ${wsId}`);
|
||||
},
|
||||
|
||||
_unregister() {
|
||||
if (!this._registered) return;
|
||||
if (this._active) {
|
||||
Surfaces.activate('chat');
|
||||
}
|
||||
Surfaces.unregister('editor');
|
||||
this._registered = false;
|
||||
this._wsId = null;
|
||||
this._built = false;
|
||||
this._els = null;
|
||||
this._openFiles.clear();
|
||||
this._activeFile = null;
|
||||
console.log('[EditorMode] Unregistered');
|
||||
},
|
||||
|
||||
// ── Surface Callbacks ────────────────────
|
||||
|
||||
_activate() {
|
||||
this._active = true;
|
||||
|
||||
if (!this._built) {
|
||||
this._build();
|
||||
}
|
||||
|
||||
// Populate regions
|
||||
const regions = Surfaces._regionEls;
|
||||
|
||||
// Header
|
||||
const headerEl = regions.get('surface-header');
|
||||
if (headerEl) headerEl.appendChild(this._els.header);
|
||||
|
||||
// Sidebar → file tree
|
||||
const sidebarEl = regions.get('sidebar-content');
|
||||
if (sidebarEl) sidebarEl.appendChild(this._els.fileTree);
|
||||
|
||||
// Main → split pane (editor + chat)
|
||||
const mainEl = regions.get('surface-main');
|
||||
if (mainEl) mainEl.appendChild(this._els.main);
|
||||
|
||||
// Footer → status bar
|
||||
const footerEl = regions.get('surface-footer');
|
||||
if (footerEl) footerEl.appendChild(this._els.footer);
|
||||
|
||||
// Embed saved chat DOM into our chat pane
|
||||
this._embedChat();
|
||||
|
||||
// Refresh file tree
|
||||
this._refreshFileTree();
|
||||
this._refreshGitBranch();
|
||||
|
||||
// Focus active editor
|
||||
if (this._activeFile) {
|
||||
const f = this._openFiles.get(this._activeFile);
|
||||
if (f?.editor?.focus) setTimeout(() => f.editor.focus(), 50);
|
||||
}
|
||||
},
|
||||
|
||||
_deactivate() {
|
||||
this._active = false;
|
||||
|
||||
// Return chat DOM to Surfaces saved store before regions are saved
|
||||
this._returnChat();
|
||||
},
|
||||
|
||||
// ── Chat Panel Embedding ─────────────────
|
||||
// Borrow chat DOM from the saved fragments so the chat panel
|
||||
// shows real messages and the user can interact with AI.
|
||||
|
||||
_chatPane: null,
|
||||
_chatMessagesSlot: null,
|
||||
_chatInputSlot: null,
|
||||
|
||||
_embedChat() {
|
||||
if (!this._chatPane) return;
|
||||
|
||||
// Grab saved chat fragments
|
||||
const msgFrag = Surfaces.getSavedFragment('chat', 'surface-main');
|
||||
const inputFrag = Surfaces.getSavedFragment('chat', 'surface-footer');
|
||||
|
||||
if (msgFrag) {
|
||||
this._chatMessagesSlot.appendChild(msgFrag);
|
||||
// Scroll to bottom
|
||||
this._chatMessagesSlot.scrollTop = this._chatMessagesSlot.scrollHeight;
|
||||
}
|
||||
if (inputFrag) {
|
||||
this._chatInputSlot.appendChild(inputFrag);
|
||||
}
|
||||
},
|
||||
|
||||
_returnChat() {
|
||||
if (!this._chatPane) return;
|
||||
|
||||
// Collect chat DOM back into fragments and return to Surfaces store
|
||||
const msgFrag = document.createDocumentFragment();
|
||||
while (this._chatMessagesSlot.firstChild) {
|
||||
msgFrag.appendChild(this._chatMessagesSlot.firstChild);
|
||||
}
|
||||
Surfaces.putSavedFragment('chat', 'surface-main', msgFrag);
|
||||
|
||||
const inputFrag = document.createDocumentFragment();
|
||||
while (this._chatInputSlot.firstChild) {
|
||||
inputFrag.appendChild(this._chatInputSlot.firstChild);
|
||||
}
|
||||
Surfaces.putSavedFragment('chat', 'surface-footer', inputFrag);
|
||||
},
|
||||
|
||||
// ── DOM Construction ─────────────────────
|
||||
|
||||
_build() {
|
||||
this._els = {
|
||||
header: this._buildHeader(),
|
||||
fileTree: this._buildFileTreeContainer(),
|
||||
main: this._buildMain(),
|
||||
footer: this._buildFooter(),
|
||||
};
|
||||
this._built = true;
|
||||
},
|
||||
|
||||
_buildHeader() {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'editor-header';
|
||||
el.innerHTML = `
|
||||
<div class="editor-header-left">
|
||||
<span class="editor-ws-name" id="editorWsName">${this._esc(this._wsName)}</span>
|
||||
<span class="editor-branch" id="editorBranch"></span>
|
||||
</div>
|
||||
<div class="editor-header-right">
|
||||
<button class="editor-hdr-btn" id="editorRefreshBtn" title="Refresh file tree">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
</button>
|
||||
</div>`;
|
||||
el.querySelector('#editorRefreshBtn').addEventListener('click', () => this._refreshFileTree());
|
||||
return el;
|
||||
},
|
||||
|
||||
_buildFileTreeContainer() {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'editor-file-tree';
|
||||
el.innerHTML = '<div class="editor-tree-loading">Loading files…</div>';
|
||||
return el;
|
||||
},
|
||||
|
||||
_buildMain() {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'editor-main';
|
||||
|
||||
// Left: editor area (tabs + code)
|
||||
const leftPane = document.createElement('div');
|
||||
leftPane.className = 'editor-left-pane';
|
||||
|
||||
this._tabBarEl = document.createElement('div');
|
||||
this._tabBarEl.className = 'editor-tab-bar';
|
||||
leftPane.appendChild(this._tabBarEl);
|
||||
|
||||
this._editorAreaEl = document.createElement('div');
|
||||
this._editorAreaEl.className = 'editor-code-area';
|
||||
leftPane.appendChild(this._editorAreaEl);
|
||||
|
||||
// Welcome screen
|
||||
this._welcomeEl = document.createElement('div');
|
||||
this._welcomeEl.className = 'editor-welcome';
|
||||
this._welcomeEl.innerHTML = `
|
||||
<div class="editor-welcome-inner">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.3"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
<p>Select a file to start editing</p>
|
||||
<p class="editor-welcome-hint"><kbd>Ctrl+P</kbd> Quick Open</p>
|
||||
</div>`;
|
||||
this._editorAreaEl.appendChild(this._welcomeEl);
|
||||
|
||||
// Resize handle
|
||||
const handle = document.createElement('div');
|
||||
handle.className = 'editor-split-handle';
|
||||
this._initResize(handle, leftPane);
|
||||
|
||||
// Right: chat panel
|
||||
const rightPane = document.createElement('div');
|
||||
rightPane.className = 'editor-right-pane';
|
||||
|
||||
const chatHeader = document.createElement('div');
|
||||
chatHeader.className = 'editor-chat-header';
|
||||
chatHeader.innerHTML = '<span>AI Chat</span>';
|
||||
rightPane.appendChild(chatHeader);
|
||||
|
||||
this._chatMessagesSlot = document.createElement('div');
|
||||
this._chatMessagesSlot.className = 'editor-chat-messages';
|
||||
rightPane.appendChild(this._chatMessagesSlot);
|
||||
|
||||
this._chatInputSlot = document.createElement('div');
|
||||
this._chatInputSlot.className = 'editor-chat-input';
|
||||
rightPane.appendChild(this._chatInputSlot);
|
||||
|
||||
this._chatPane = rightPane;
|
||||
|
||||
el.appendChild(leftPane);
|
||||
el.appendChild(handle);
|
||||
el.appendChild(rightPane);
|
||||
|
||||
// Apply initial split ratio
|
||||
leftPane.style.flex = `0 0 ${this._splitRatio * 100}%`;
|
||||
rightPane.style.flex = '1';
|
||||
|
||||
return el;
|
||||
},
|
||||
|
||||
_buildFooter() {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'editor-status-bar';
|
||||
el.innerHTML = `
|
||||
<span class="editor-status-file" id="editorStatusFile">No file open</span>
|
||||
<span class="editor-status-sep">│</span>
|
||||
<span class="editor-status-cursor" id="editorStatusCursor"></span>
|
||||
<span class="editor-status-sep">│</span>
|
||||
<span class="editor-status-lang" id="editorStatusLang"></span>
|
||||
<span class="editor-status-right">
|
||||
<span class="editor-status-branch" id="editorStatusBranch"></span>
|
||||
</span>`;
|
||||
return el;
|
||||
},
|
||||
|
||||
// ── Resize Handle ────────────────────────
|
||||
|
||||
_initResize(handle, leftPane) {
|
||||
let startX = 0, startWidth = 0;
|
||||
|
||||
const onMove = (e) => {
|
||||
const dx = (e.clientX || e.touches?.[0]?.clientX || 0) - startX;
|
||||
const parentWidth = handle.parentElement.clientWidth;
|
||||
const newWidth = Math.max(200, Math.min(parentWidth - 200, startWidth + dx));
|
||||
leftPane.style.flex = `0 0 ${newWidth}px`;
|
||||
this._splitRatio = newWidth / parentWidth;
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.removeEventListener('touchmove', onMove);
|
||||
document.removeEventListener('touchend', onUp);
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
};
|
||||
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
startX = e.clientX;
|
||||
startWidth = leftPane.clientWidth;
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
|
||||
handle.addEventListener('touchstart', (e) => {
|
||||
startX = e.touches[0].clientX;
|
||||
startWidth = leftPane.clientWidth;
|
||||
document.addEventListener('touchmove', onMove, { passive: false });
|
||||
document.addEventListener('touchend', onUp);
|
||||
});
|
||||
},
|
||||
|
||||
// ── File Tree ────────────────────────────
|
||||
|
||||
async _refreshFileTree() {
|
||||
if (!this._wsId) return;
|
||||
try {
|
||||
const resp = await API.listWorkspaceFiles(this._wsId, '', true);
|
||||
this._treeData = resp.files || resp.data || resp || [];
|
||||
this._renderFileTree();
|
||||
} catch (e) {
|
||||
console.error('[EditorMode] Failed to load file tree:', e);
|
||||
this._els.fileTree.innerHTML = '<div class="editor-tree-error">Failed to load files</div>';
|
||||
}
|
||||
},
|
||||
|
||||
_renderFileTree() {
|
||||
const container = this._els.fileTree;
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!this._treeData.length) {
|
||||
container.innerHTML = '<div class="editor-tree-empty">No files</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build tree structure from flat list
|
||||
const tree = this._buildTreeStructure(this._treeData);
|
||||
this._renderTreeNodes(tree, container, 0);
|
||||
},
|
||||
|
||||
_buildTreeStructure(files) {
|
||||
// files: [{path, is_directory, size_bytes, content_type}, ...]
|
||||
// Build nested tree: { name, path, isDir, children[], file }
|
||||
const root = { name: '', path: '', isDir: true, children: [], file: null };
|
||||
const dirs = new Map();
|
||||
dirs.set('', root);
|
||||
|
||||
// Sort: directories first, then alphabetical
|
||||
const sorted = [...files].sort((a, b) => {
|
||||
if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1;
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
|
||||
for (const f of sorted) {
|
||||
const parts = f.path.split('/');
|
||||
const name = parts[parts.length - 1];
|
||||
const parentPath = parts.slice(0, -1).join('/');
|
||||
|
||||
// Ensure parent directories exist
|
||||
let parent = dirs.get(parentPath);
|
||||
if (!parent) {
|
||||
// Create implicit parent dirs
|
||||
let accumulated = '';
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const prev = accumulated;
|
||||
accumulated = accumulated ? accumulated + '/' + parts[i] : parts[i];
|
||||
if (!dirs.has(accumulated)) {
|
||||
const dirNode = { name: parts[i], path: accumulated, isDir: true, children: [], file: null };
|
||||
dirs.set(accumulated, dirNode);
|
||||
const p = dirs.get(prev);
|
||||
if (p) p.children.push(dirNode);
|
||||
}
|
||||
}
|
||||
parent = dirs.get(parentPath);
|
||||
}
|
||||
|
||||
if (f.is_directory) {
|
||||
let existing = dirs.get(f.path);
|
||||
if (!existing) {
|
||||
existing = { name, path: f.path, isDir: true, children: [], file: f };
|
||||
dirs.set(f.path, existing);
|
||||
if (parent) parent.children.push(existing);
|
||||
} else {
|
||||
existing.file = f;
|
||||
}
|
||||
} else {
|
||||
const node = { name, path: f.path, isDir: false, children: [], file: f };
|
||||
if (parent) parent.children.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return root.children;
|
||||
},
|
||||
|
||||
_renderTreeNodes(nodes, container, depth) {
|
||||
for (const node of nodes) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'editor-tree-row' + (node.path === this._activeFile ? ' active' : '');
|
||||
row.style.paddingLeft = (12 + depth * 16) + 'px';
|
||||
row.dataset.path = node.path;
|
||||
|
||||
if (node.isDir) {
|
||||
const expanded = this._expandedDirs.has(node.path);
|
||||
row.innerHTML = `
|
||||
<span class="editor-tree-arrow ${expanded ? 'expanded' : ''}">${expanded ? '▾' : '▸'}</span>
|
||||
<span class="editor-tree-icon">📁</span>
|
||||
<span class="editor-tree-name">${this._esc(node.name)}</span>`;
|
||||
row.addEventListener('click', () => {
|
||||
if (this._expandedDirs.has(node.path)) {
|
||||
this._expandedDirs.delete(node.path);
|
||||
} else {
|
||||
this._expandedDirs.add(node.path);
|
||||
}
|
||||
this._renderFileTree();
|
||||
});
|
||||
container.appendChild(row);
|
||||
|
||||
if (expanded && node.children.length) {
|
||||
this._renderTreeNodes(node.children, container, depth + 1);
|
||||
}
|
||||
} else {
|
||||
row.innerHTML = `
|
||||
<span class="editor-tree-icon">${this._fileIcon(node.name)}</span>
|
||||
<span class="editor-tree-name">${this._esc(node.name)}</span>`;
|
||||
row.addEventListener('click', () => this._openFile(node.path));
|
||||
|
||||
// Context menu
|
||||
row.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
this._showFileContextMenu(e, node);
|
||||
});
|
||||
|
||||
container.appendChild(row);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_fileIcon(name) {
|
||||
const ext = name.split('.').pop()?.toLowerCase();
|
||||
const icons = {
|
||||
js: '📜', ts: '📜', jsx: '⚛', tsx: '⚛',
|
||||
go: '🔵', py: '🐍', rs: '🦀', rb: '💎',
|
||||
html: '🌐', css: '🎨', scss: '🎨', less: '🎨',
|
||||
json: '📋', yaml: '📋', yml: '📋', toml: '📋',
|
||||
md: '📝', txt: '📄', svg: '🖼',
|
||||
sql: '🗃', sh: '⚙', bash: '⚙', zsh: '⚙',
|
||||
dockerfile: '🐳', makefile: '🔧',
|
||||
mod: '📦', sum: '📦', lock: '🔒',
|
||||
};
|
||||
return icons[ext] || '📄';
|
||||
},
|
||||
|
||||
_showFileContextMenu(e, node) {
|
||||
// Simple context menu using CSS positioned div
|
||||
const existing = document.querySelector('.editor-ctx-menu');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'editor-ctx-menu';
|
||||
menu.style.left = e.clientX + 'px';
|
||||
menu.style.top = e.clientY + 'px';
|
||||
|
||||
const items = [
|
||||
{ label: 'Open', action: () => this._openFile(node.path) },
|
||||
{ label: 'Delete', action: () => this._deleteFile(node.path) },
|
||||
];
|
||||
for (const item of items) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'editor-ctx-item';
|
||||
row.textContent = item.label;
|
||||
row.addEventListener('click', () => { menu.remove(); item.action(); });
|
||||
menu.appendChild(row);
|
||||
}
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
const dismiss = (ev) => {
|
||||
if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', dismiss); }
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', dismiss), 0);
|
||||
},
|
||||
|
||||
// ── Tab Management ───────────────────────
|
||||
|
||||
async _openFile(path) {
|
||||
if (this._openFiles.has(path)) {
|
||||
this._activateTab(path);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch file content
|
||||
let content = '';
|
||||
try {
|
||||
content = await API.readWorkspaceFile(this._wsId, path);
|
||||
} catch (e) {
|
||||
console.error('[EditorMode] Failed to read file:', e);
|
||||
if (typeof UI !== 'undefined') UI.toast(`Failed to open ${path}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const lang = this._detectLanguage(path);
|
||||
|
||||
// Create tab
|
||||
const tab = document.createElement('div');
|
||||
tab.className = 'editor-tab';
|
||||
tab.dataset.path = path;
|
||||
tab.innerHTML = `
|
||||
<span class="editor-tab-icon">${this._fileIcon(path.split('/').pop())}</span>
|
||||
<span class="editor-tab-name">${this._esc(path.split('/').pop())}</span>
|
||||
<span class="editor-tab-modified" style="display:none">●</span>
|
||||
<button class="editor-tab-close" title="Close">✕</button>`;
|
||||
tab.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.editor-tab-close')) this._activateTab(path);
|
||||
});
|
||||
tab.querySelector('.editor-tab-close').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._closeFile(path);
|
||||
});
|
||||
this._tabBarEl.appendChild(tab);
|
||||
|
||||
// Create editor wrapper
|
||||
const editorWrap = document.createElement('div');
|
||||
editorWrap.className = 'editor-cm-wrap';
|
||||
editorWrap.style.display = 'none';
|
||||
this._editorAreaEl.appendChild(editorWrap);
|
||||
|
||||
// Create CM6 editor or textarea fallback
|
||||
let editor = null;
|
||||
if (window.CM?.codeEditor) {
|
||||
editor = CM.codeEditor(editorWrap, {
|
||||
language: lang,
|
||||
value: content,
|
||||
lineNumbers: true,
|
||||
});
|
||||
// Listen for changes to mark modified
|
||||
if (editor.onUpdate) {
|
||||
editor.onUpdate(() => this._markModified(path));
|
||||
}
|
||||
} else {
|
||||
// Textarea fallback
|
||||
const ta = document.createElement('textarea');
|
||||
ta.className = 'editor-textarea-fallback';
|
||||
ta.value = content;
|
||||
ta.addEventListener('input', () => this._markModified(path));
|
||||
editorWrap.appendChild(ta);
|
||||
editor = {
|
||||
getValue: () => ta.value,
|
||||
setValue: (v) => { ta.value = v; },
|
||||
focus: () => ta.focus(),
|
||||
destroy: () => {},
|
||||
_textarea: true,
|
||||
};
|
||||
}
|
||||
|
||||
this._openFiles.set(path, {
|
||||
tab, editorWrap, editor, content, modified: false, language: lang,
|
||||
});
|
||||
|
||||
this._activateTab(path);
|
||||
},
|
||||
|
||||
_activateTab(path) {
|
||||
if (this._activeFile === path) return;
|
||||
|
||||
// Deactivate previous
|
||||
if (this._activeFile && this._openFiles.has(this._activeFile)) {
|
||||
const prev = this._openFiles.get(this._activeFile);
|
||||
prev.tab.classList.remove('active');
|
||||
prev.editorWrap.style.display = 'none';
|
||||
}
|
||||
|
||||
this._activeFile = path;
|
||||
const f = this._openFiles.get(path);
|
||||
if (!f) return;
|
||||
|
||||
f.tab.classList.add('active');
|
||||
f.editorWrap.style.display = '';
|
||||
this._welcomeEl.style.display = 'none';
|
||||
|
||||
// Update status bar
|
||||
this._updateStatusBar();
|
||||
|
||||
// Update file tree active state
|
||||
this._els.fileTree.querySelectorAll('.editor-tree-row').forEach(row => {
|
||||
row.classList.toggle('active', row.dataset.path === path);
|
||||
});
|
||||
|
||||
// Focus editor
|
||||
if (f.editor?.focus) setTimeout(() => f.editor.focus(), 10);
|
||||
},
|
||||
|
||||
async _closeFile(path) {
|
||||
const f = this._openFiles.get(path);
|
||||
if (!f) return;
|
||||
|
||||
// Confirm if modified
|
||||
if (f.modified) {
|
||||
const save = await this._confirmClose(path);
|
||||
if (save === null) return; // cancelled
|
||||
if (save) await this._saveFile(path);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
f.tab.remove();
|
||||
f.editorWrap.remove();
|
||||
if (f.editor?.destroy) f.editor.destroy();
|
||||
this._openFiles.delete(path);
|
||||
|
||||
// Activate next tab
|
||||
if (this._activeFile === path) {
|
||||
this._activeFile = null;
|
||||
const remaining = Array.from(this._openFiles.keys());
|
||||
if (remaining.length) {
|
||||
this._activateTab(remaining[remaining.length - 1]);
|
||||
} else {
|
||||
this._welcomeEl.style.display = '';
|
||||
this._updateStatusBar();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async _confirmClose(path) {
|
||||
if (typeof showConfirm === 'function') {
|
||||
const r = await showConfirm(`Save changes to ${path.split('/').pop()}?`, {
|
||||
confirmLabel: 'Save', cancelLabel: 'Discard', showCancel: true
|
||||
});
|
||||
return r; // true = save, false = discard
|
||||
}
|
||||
return window.confirm(`Save changes to ${path.split('/').pop()}?`);
|
||||
},
|
||||
|
||||
_markModified(path) {
|
||||
const f = this._openFiles.get(path);
|
||||
if (!f || f.modified) return;
|
||||
const currentVal = f.editor?.getValue?.() ?? '';
|
||||
if (currentVal === f.content) return;
|
||||
f.modified = true;
|
||||
f.tab.querySelector('.editor-tab-modified').style.display = '';
|
||||
f.tab.classList.add('modified');
|
||||
},
|
||||
|
||||
// ── Save ─────────────────────────────────
|
||||
|
||||
async _saveFile(path) {
|
||||
const f = this._openFiles.get(path);
|
||||
if (!f) return;
|
||||
|
||||
const content = f.editor?.getValue?.() ?? '';
|
||||
try {
|
||||
await API.writeWorkspaceFile(this._wsId, path, content);
|
||||
f.content = content;
|
||||
f.modified = false;
|
||||
f.tab.querySelector('.editor-tab-modified').style.display = 'none';
|
||||
f.tab.classList.remove('modified');
|
||||
if (typeof UI !== 'undefined') UI.toast(`Saved ${path.split('/').pop()}`, 'success');
|
||||
} catch (e) {
|
||||
console.error('[EditorMode] Save failed:', e);
|
||||
if (typeof UI !== 'undefined') UI.toast(`Failed to save: ${e.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async _saveActiveFile() {
|
||||
if (this._activeFile) await this._saveFile(this._activeFile);
|
||||
},
|
||||
|
||||
async _deleteFile(path) {
|
||||
const ok = typeof showConfirm === 'function'
|
||||
? await showConfirm(`Delete ${path}?`)
|
||||
: window.confirm(`Delete ${path}?`);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await API.deleteWorkspaceFile(this._wsId, path);
|
||||
// Close tab if open
|
||||
if (this._openFiles.has(path)) {
|
||||
const f = this._openFiles.get(path);
|
||||
f.modified = false; // skip save prompt
|
||||
await this._closeFile(path);
|
||||
}
|
||||
this._refreshFileTree();
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast(`Delete failed: ${e.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Language Detection ────────────────────
|
||||
|
||||
_detectLanguage(path) {
|
||||
const ext = path.split('.').pop()?.toLowerCase();
|
||||
const map = {
|
||||
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
||||
ts: 'typescript', tsx: 'typescript', jsx: 'javascript',
|
||||
go: 'go', py: 'python', rs: 'rust', rb: 'ruby',
|
||||
html: 'html', htm: 'html', css: 'css', scss: 'css', less: 'css',
|
||||
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
|
||||
md: 'markdown', mdx: 'markdown',
|
||||
sql: 'sql', sh: 'shell', bash: 'shell', zsh: 'shell',
|
||||
xml: 'xml', svg: 'xml',
|
||||
c: 'cpp', cpp: 'cpp', h: 'cpp', hpp: 'cpp',
|
||||
java: 'java', kt: 'java',
|
||||
php: 'php', pl: 'perl',
|
||||
dockerfile: 'dockerfile',
|
||||
};
|
||||
return map[ext] || 'text';
|
||||
},
|
||||
|
||||
// ── Git ──────────────────────────────────
|
||||
|
||||
async _refreshGitBranch() {
|
||||
if (!this._wsId) return;
|
||||
try {
|
||||
const resp = await API.getWorkspaceGitBranches(this._wsId);
|
||||
this._gitBranch = resp.current || null;
|
||||
this._updateBranchDisplay();
|
||||
} catch (_) {
|
||||
this._gitBranch = null;
|
||||
this._updateBranchDisplay();
|
||||
}
|
||||
},
|
||||
|
||||
_updateBranchDisplay() {
|
||||
const headerEl = this._els?.header?.querySelector('#editorBranch');
|
||||
const statusEl = this._els?.footer?.querySelector('#editorStatusBranch');
|
||||
const branchText = this._gitBranch ? `⎇ ${this._gitBranch}` : '';
|
||||
|
||||
if (headerEl) headerEl.textContent = branchText;
|
||||
if (statusEl) statusEl.textContent = branchText;
|
||||
},
|
||||
|
||||
// ── Status Bar ───────────────────────────
|
||||
|
||||
_updateStatusBar() {
|
||||
if (!this._els?.footer) return;
|
||||
const fileEl = this._els.footer.querySelector('#editorStatusFile');
|
||||
const langEl = this._els.footer.querySelector('#editorStatusLang');
|
||||
const cursorEl = this._els.footer.querySelector('#editorStatusCursor');
|
||||
|
||||
if (this._activeFile) {
|
||||
const f = this._openFiles.get(this._activeFile);
|
||||
if (fileEl) fileEl.textContent = this._activeFile;
|
||||
if (langEl) langEl.textContent = f?.language || '';
|
||||
if (cursorEl) cursorEl.textContent = '';
|
||||
} else {
|
||||
if (fileEl) fileEl.textContent = 'No file open';
|
||||
if (langEl) langEl.textContent = '';
|
||||
if (cursorEl) cursorEl.textContent = '';
|
||||
}
|
||||
},
|
||||
|
||||
// ── Quick Open (Ctrl+P) ──────────────────
|
||||
|
||||
_quickOpenEl: null,
|
||||
|
||||
_showQuickOpen() {
|
||||
if (this._quickOpenEl) { this._quickOpenEl.remove(); this._quickOpenEl = null; return; }
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'editor-quick-open';
|
||||
overlay.innerHTML = `
|
||||
<div class="editor-qo-dialog">
|
||||
<input class="editor-qo-input" type="text" placeholder="Open file…" autocomplete="off">
|
||||
<div class="editor-qo-results"></div>
|
||||
</div>`;
|
||||
|
||||
const input = overlay.querySelector('.editor-qo-input');
|
||||
const results = overlay.querySelector('.editor-qo-results');
|
||||
|
||||
const allFiles = this._treeData.filter(f => !f.is_directory);
|
||||
|
||||
const render = (query) => {
|
||||
results.innerHTML = '';
|
||||
const q = query.toLowerCase();
|
||||
const matches = q
|
||||
? allFiles.filter(f => f.path.toLowerCase().includes(q)).slice(0, 20)
|
||||
: allFiles.slice(0, 20);
|
||||
|
||||
for (const f of matches) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'editor-qo-row';
|
||||
row.innerHTML = `<span class="editor-qo-icon">${this._fileIcon(f.path.split('/').pop())}</span> ${this._esc(f.path)}`;
|
||||
row.addEventListener('click', () => {
|
||||
overlay.remove();
|
||||
this._quickOpenEl = null;
|
||||
this._openFile(f.path);
|
||||
});
|
||||
results.appendChild(row);
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('input', () => render(input.value));
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { overlay.remove(); this._quickOpenEl = null; }
|
||||
if (e.key === 'Enter') {
|
||||
const first = results.querySelector('.editor-qo-row');
|
||||
if (first) first.click();
|
||||
}
|
||||
});
|
||||
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) { overlay.remove(); this._quickOpenEl = null; }
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
this._quickOpenEl = overlay;
|
||||
render('');
|
||||
input.focus();
|
||||
},
|
||||
|
||||
// ── Keyboard Shortcuts ───────────────────
|
||||
|
||||
_onKeyDown(e) {
|
||||
if (!EditorMode._active) return;
|
||||
|
||||
// Ctrl/Cmd+S — save
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
EditorMode._saveActiveFile();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+P — quick open
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
|
||||
e.preventDefault();
|
||||
EditorMode._showQuickOpen();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+W — close tab
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
|
||||
if (EditorMode._activeFile) {
|
||||
e.preventDefault();
|
||||
EditorMode._closeFile(EditorMode._activeFile);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Live Update on workspace.file.changed ─
|
||||
|
||||
_onFileChanged(ev) {
|
||||
if (!EditorMode._active) return;
|
||||
const { path } = ev || {};
|
||||
if (!path) return;
|
||||
|
||||
// If the file is open, refresh its content
|
||||
const f = EditorMode._openFiles.get(path);
|
||||
if (f && !f.modified) {
|
||||
API.readWorkspaceFile(EditorMode._wsId, path).then(content => {
|
||||
if (f.editor?.setValue) f.editor.setValue(content);
|
||||
f.content = content;
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Also refresh file tree
|
||||
EditorMode._refreshFileTree();
|
||||
},
|
||||
|
||||
// ── Helpers ──────────────────────────────
|
||||
|
||||
_esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s || '';
|
||||
return d.innerHTML;
|
||||
},
|
||||
|
||||
// ── Global Init ──────────────────────────
|
||||
|
||||
init() {
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', this._onKeyDown);
|
||||
|
||||
// Listen for channel switches to check workspace availability
|
||||
Events.on('chat.switched', () => this.check());
|
||||
Events.on('chat.created', () => this.check());
|
||||
|
||||
// Listen for workspace file changes (from AI tool calls)
|
||||
Events.on('workspace.file.changed', this._onFileChanged);
|
||||
|
||||
// Initial check
|
||||
setTimeout(() => this.check(), 500);
|
||||
|
||||
console.log('[EditorMode] Initialized');
|
||||
},
|
||||
};
|
||||
|
||||
// Auto-init after DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => EditorMode.init());
|
||||
} else {
|
||||
EditorMode.init();
|
||||
}
|
||||
@@ -240,6 +240,12 @@ function showChatContextMenu(e, chatId) {
|
||||
<span class="ctx-icon">+</span> New project…
|
||||
</button>`;
|
||||
|
||||
// Workspace override (v0.21.5)
|
||||
items += '<div class="ctx-divider"></div>';
|
||||
items += `<button class="ctx-item" onclick="showWorkspacePicker('${chatId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">📁</span> Set workspace…
|
||||
</button>`;
|
||||
|
||||
menu.innerHTML = items;
|
||||
|
||||
// Position near cursor
|
||||
@@ -392,6 +398,16 @@ function _registerProjectPanel() {
|
||||
<label class="project-panel-label">Notes</label>
|
||||
<div id="projectNoteList" class="project-panel-list"></div>
|
||||
</div>
|
||||
<div class="project-panel-section">
|
||||
<label class="project-panel-label">Workspace</label>
|
||||
<div class="project-panel-ws-row" id="projectWsRow">
|
||||
<select id="projectWsSelect" class="project-panel-select">
|
||||
<option value="">None</option>
|
||||
</select>
|
||||
<button class="btn-small" id="projectWsCreate" title="Create workspace">+ New</button>
|
||||
</div>
|
||||
<div class="project-panel-hint">Workspace files are available to AI in all project chats. Enables the Editor surface.</div>
|
||||
</div>
|
||||
<div class="project-panel-section project-panel-footer">
|
||||
<label class="project-panel-checkbox">
|
||||
<input type="checkbox" id="projectArchiveToggle">
|
||||
@@ -413,6 +429,10 @@ function _registerProjectPanel() {
|
||||
// Wire archive toggle (v0.19.2)
|
||||
el.querySelector('#projectArchiveToggle').addEventListener('change', _toggleProjectArchive);
|
||||
|
||||
// Wire workspace select + create (v0.21.5)
|
||||
el.querySelector('#projectWsSelect').addEventListener('change', _saveProjectWorkspace);
|
||||
el.querySelector('#projectWsCreate').addEventListener('click', _createProjectWorkspace);
|
||||
|
||||
PanelRegistry.register('project', {
|
||||
element: el,
|
||||
label: 'Project',
|
||||
@@ -457,6 +477,9 @@ async function _loadProjectPanel(projectId) {
|
||||
|
||||
// Load notes
|
||||
await _renderProjectNotes(projectId);
|
||||
|
||||
// Load workspace picker (v0.21.5)
|
||||
await _renderProjectWorkspace(projectId);
|
||||
}
|
||||
|
||||
async function _saveProjectPrompt() {
|
||||
@@ -643,6 +666,143 @@ async function _toggleProjectArchive() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workspace picker (v0.21.5) ───────────────
|
||||
|
||||
async function _renderProjectWorkspace(projectId) {
|
||||
const sel = document.getElementById('projectWsSelect');
|
||||
if (!sel) return;
|
||||
|
||||
// Fetch current project state + available workspaces in parallel
|
||||
const [proj, workspaces] = await Promise.all([
|
||||
API.getProject(projectId).catch(() => null),
|
||||
API.listWorkspaces().catch(() => []),
|
||||
]);
|
||||
|
||||
const currentWsId = proj?.workspace_id || '';
|
||||
const wsList = Array.isArray(workspaces) ? workspaces : (workspaces?.data || []);
|
||||
|
||||
sel.innerHTML = '<option value="">None</option>';
|
||||
for (const ws of wsList) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ws.id;
|
||||
opt.textContent = ws.name || ws.id.slice(0, 8);
|
||||
if (ws.id === currentWsId) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function _saveProjectWorkspace() {
|
||||
if (!_projectPanelId) return;
|
||||
const wsId = document.getElementById('projectWsSelect').value;
|
||||
try {
|
||||
await API.updateProject(_projectPanelId, { workspace_id: wsId || null });
|
||||
UI.toast(wsId ? 'Workspace linked' : 'Workspace unlinked', 'success');
|
||||
// Trigger editor surface check
|
||||
if (typeof EditorMode !== 'undefined') EditorMode.check();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to update workspace', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function _createProjectWorkspace() {
|
||||
if (!_projectPanelId) return;
|
||||
const proj = App.projects.find(p => p.id === _projectPanelId);
|
||||
const name = prompt('Workspace name:', (proj?.name || 'Project') + ' Workspace');
|
||||
if (!name || !name.trim()) return;
|
||||
|
||||
try {
|
||||
const ws = await API.createWorkspace({
|
||||
name: name.trim(),
|
||||
owner_type: 'user',
|
||||
owner_id: API.user?.id || '',
|
||||
});
|
||||
// Auto-bind to project
|
||||
await API.updateProject(_projectPanelId, { workspace_id: ws.id });
|
||||
UI.toast('Workspace created and linked', 'success');
|
||||
// Refresh the picker
|
||||
await _renderProjectWorkspace(_projectPanelId);
|
||||
// Trigger editor surface check
|
||||
if (typeof EditorMode !== 'undefined') EditorMode.check();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to create workspace: ' + (e.message || e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channel workspace picker (v0.21.5) ───────
|
||||
|
||||
async function showWorkspacePicker(chatId) {
|
||||
let workspaces = [];
|
||||
try {
|
||||
const resp = await API.listWorkspaces();
|
||||
workspaces = Array.isArray(resp) ? resp : (resp?.data || []);
|
||||
} catch (_) {}
|
||||
|
||||
// Get current channel workspace
|
||||
let currentWsId = '';
|
||||
try {
|
||||
const ch = await API.getChannel(chatId);
|
||||
currentWsId = ch.workspace_id || '';
|
||||
} catch (_) {}
|
||||
|
||||
// Build options
|
||||
const options = [{ value: '', label: 'None (inherit from project)' }];
|
||||
for (const ws of workspaces) {
|
||||
options.push({ value: ws.id, label: ws.name || ws.id.slice(0, 8) });
|
||||
}
|
||||
options.push({ value: '__new__', label: '+ Create new workspace…' });
|
||||
|
||||
// Use simple select via popup
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'editor-quick-open'; // reuse overlay style
|
||||
overlay.innerHTML = `
|
||||
<div class="editor-qo-dialog" style="width:340px">
|
||||
<div style="padding:10px 14px; font-weight:600; border-bottom:1px solid var(--border)">Set Workspace</div>
|
||||
<div class="editor-qo-results" id="wsPickerList"></div>
|
||||
</div>`;
|
||||
|
||||
const list = overlay.querySelector('#wsPickerList');
|
||||
for (const opt of options) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'editor-qo-row' + (opt.value === currentWsId ? ' active' : '');
|
||||
row.style.fontWeight = opt.value === currentWsId ? '600' : '';
|
||||
row.textContent = opt.label;
|
||||
row.addEventListener('click', async () => {
|
||||
overlay.remove();
|
||||
if (opt.value === '__new__') {
|
||||
const name = prompt('Workspace name:');
|
||||
if (!name || !name.trim()) return;
|
||||
try {
|
||||
const ws = await API.createWorkspace({
|
||||
name: name.trim(),
|
||||
owner_type: 'user',
|
||||
owner_id: API.user?.id || '',
|
||||
});
|
||||
await API.updateChannel(chatId, { workspace_id: ws.id });
|
||||
UI.toast('Workspace created and linked', 'success');
|
||||
if (typeof EditorMode !== 'undefined') EditorMode.check();
|
||||
} catch (e) {
|
||||
UI.toast('Failed: ' + (e.message || e), 'error');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await API.updateChannel(chatId, { workspace_id: opt.value || '' });
|
||||
UI.toast(opt.value ? 'Workspace linked' : 'Workspace unlinked', 'success');
|
||||
if (typeof EditorMode !== 'undefined') EditorMode.check();
|
||||
} catch (e) {
|
||||
UI.toast('Failed: ' + (e.message || e), 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
list.appendChild(row);
|
||||
}
|
||||
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// CHANNEL REORDER WITHIN PROJECT (v0.19.2)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@@ -167,6 +167,28 @@ const Surfaces = {
|
||||
return this._registry.size > 1;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a saved DocumentFragment for a specific surface + region.
|
||||
* Used by surfaces like editor-mode that want to embed chat DOM
|
||||
* inside their own layout.
|
||||
* @param {string} surfaceId — the surface that owns the saved DOM
|
||||
* @param {string} regionId — the region name
|
||||
* @returns {DocumentFragment|null}
|
||||
*/
|
||||
getSavedFragment(surfaceId, regionId) {
|
||||
const key = `${surfaceId}::${regionId}`;
|
||||
return this._saved.get(key) || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Put a DocumentFragment back into the saved store.
|
||||
* Used during deactivation to return borrowed DOM.
|
||||
*/
|
||||
putSavedFragment(surfaceId, regionId, frag) {
|
||||
const key = `${surfaceId}::${regionId}`;
|
||||
this._saved.set(key, frag);
|
||||
},
|
||||
|
||||
// ── Region Management ────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user