feat: IDE编辑器重构 — 多标签+文件树+最小化+状态栏
- 全屏IDE面板替代模态框编辑器 - 多文件标签页: 独立Monaco实例, 切换保持状态 - 左侧文件树: 基于当前目录, 点击文件打开, 点击目录导航 - 最小化/恢复: 底部最小化栏显示所有打开的文件标签 - 状态栏: 光标位置+语言+编码+未保存指示器 - Ctrl+S保存 + 未保存关闭确认 - 标签栏: 点击切换, ×关闭, amber色未保存标记 - 文件树同步: 浏览新目录时自动更新文件树 - 模态框保留用于非编辑操作(重命名/删除/新建等)
This commit is contained in:
+210
-59
@@ -59,17 +59,49 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Modal (supports Monaco + textarea fallback) -->
|
||||
<!-- IDE Editor Panel -->
|
||||
<div id="editorPanel" class="hidden fixed inset-0 z-40 bg-slate-950 flex">
|
||||
<!-- File tree sidebar -->
|
||||
<div id="editorTree" class="w-56 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0">
|
||||
<div class="px-3 py-2 border-b border-slate-800 flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500 font-medium">📁 文件列表</span>
|
||||
<button onclick="toggleEditorTree()" class="text-slate-600 hover:text-slate-400 text-xs">◀</button>
|
||||
</div>
|
||||
<div id="editorTreeList" class="flex-1 overflow-y-auto text-sm"></div>
|
||||
</div>
|
||||
<!-- Editor area -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Tab bar -->
|
||||
<div id="editorTabBar" class="flex bg-slate-900 border-b border-slate-800 overflow-x-auto shrink-0"></div>
|
||||
<!-- Monaco container (each tab gets its own child div) -->
|
||||
<div id="editorMonacoWrap" class="flex-1 relative"></div>
|
||||
<!-- Status bar -->
|
||||
<div class="flex items-center gap-4 px-4 py-1.5 bg-slate-900 border-t border-slate-800 text-xs text-slate-500 shrink-0">
|
||||
<span id="editorPos">Ln 1, Col 1</span>
|
||||
<span id="editorLang">plaintext</span>
|
||||
<span>UTF-8</span>
|
||||
<span id="editorMod" class="text-amber-400 hidden">● 未保存</span>
|
||||
<div class="flex-1"></div>
|
||||
<span id="editorFileName" class="text-slate-400"></span>
|
||||
<button onclick="minimizeEditor()" class="px-2 py-0.5 hover:text-white hover:bg-slate-700 rounded transition" title="最小化">—</button>
|
||||
<button onclick="closeAllTabs()" class="px-2 py-0.5 hover:text-red-400 hover:bg-red-900/30 rounded transition" title="关闭全部">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Minimized editor bar -->
|
||||
<div id="editorMinBar" class="hidden fixed bottom-0 left-0 right-0 z-30 bg-slate-900 border-t border-slate-700 px-4 py-1.5 flex items-center gap-2">
|
||||
<div id="editorMinTabs" class="flex-1 flex items-center gap-1 overflow-x-auto"></div>
|
||||
<button onclick="restoreEditor()" class="px-2 py-0.5 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded">▲ 展开</button>
|
||||
<button onclick="closeAllTabs()" class="px-2 py-0.5 text-xs text-slate-400 hover:text-red-400 hover:bg-red-900/30 rounded">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal (for non-editor operations: rename/delete/mkdir/chmod/compress) -->
|
||||
<div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div id="modalDialog" class="bg-slate-800 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl border border-slate-700">
|
||||
<h3 id="modalTitle" class="text-white font-medium mb-4 text-lg"></h3>
|
||||
<p id="modalText" class="text-slate-300 text-sm mb-4 hidden whitespace-pre-line"></p>
|
||||
<input id="modalInput" class="hidden w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white mb-4 focus:outline-none focus:ring-2 focus:ring-brand text-sm" autocomplete="off">
|
||||
<div id="monacoContainer" class="hidden mb-4 rounded-lg overflow-hidden border border-slate-600" style="height:70vh"></div>
|
||||
<textarea id="modalTextarea" class="hidden w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white mb-4 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-brand resize-y" rows="20"></textarea>
|
||||
<div id="editorMeta" class="hidden text-xs text-slate-500 mb-2 flex items-center gap-3">
|
||||
<span id="editorSize"></span><span id="editorLang"></span><span id="editorModified" class="text-amber-400 hidden">● 未保存</span>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button onclick="closeModal()" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition">取消</button>
|
||||
<button id="modalConfirm" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">确认</button>
|
||||
@@ -321,12 +353,6 @@
|
||||
_modalResolve=onConfirm;
|
||||
}
|
||||
function closeModal(){
|
||||
if(_monacoEditor){_monacoEditor.dispose();_monacoEditor=null}
|
||||
document.getElementById('monacoContainer').classList.add('hidden');
|
||||
document.getElementById('monacoContainer').innerHTML='';
|
||||
document.getElementById('editorMeta').classList.add('hidden');
|
||||
const dialog=document.getElementById('modalDialog');
|
||||
dialog.style.maxWidth='';dialog.style.width='';
|
||||
document.getElementById('modal').classList.add('hidden');_modalResolve=null;
|
||||
}
|
||||
document.getElementById('modalConfirm').onclick=()=>{
|
||||
@@ -371,6 +397,7 @@
|
||||
_allEntries=d.entries||[];
|
||||
renderFileList(_allEntries);
|
||||
preloadFiles(_allEntries); // Background preload small text files
|
||||
if(_editorVisible)renderFileTree(); // Update IDE file tree
|
||||
}catch(e){
|
||||
fileListEl.innerHTML=`<div class="px-4 py-12 text-center text-red-400">连接失败: ${esc(e.message)}</div>`;
|
||||
toast('error','连接失败');
|
||||
@@ -577,19 +604,19 @@
|
||||
}));
|
||||
}
|
||||
|
||||
// P2-8: Preview/Edit (with cache)
|
||||
// P2-8: Preview/Edit (with cache) → IDE editor
|
||||
async function doPreview(path){
|
||||
const name=path.split('/').pop();
|
||||
const cached=_fileCache[path];
|
||||
if(cached){openEditor(name,path,cached.content);return}
|
||||
if(cached){openFileInEditor(path,name,cached.content);return}
|
||||
toast('info','正在读取文件...');
|
||||
const r=await apiFetch(API+'/sync/read-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path})});
|
||||
if(!r||!r.ok){const e=r?await r.json().catch(()=>({})):{};toast('error','读取失败: '+(e.detail||''));return}
|
||||
const d=await r.json();
|
||||
_fileCache[path]={content:d.content,size:d.size};
|
||||
openEditor(name,path,d.content);
|
||||
openFileInEditor(path,name,d.content);
|
||||
}
|
||||
// ── Monaco Editor language mapping (E-4) ──
|
||||
// ── IDE Editor ──
|
||||
const LANG_MAP={conf:'ini',cfg:'ini',ini:'ini',toml:'ini',sh:'shell',bash:'shell',zsh:'shell',
|
||||
py:'python',js:'javascript',ts:'typescript',json:'json',yml:'yaml',yaml:'yaml',
|
||||
html:'html',htm:'html',css:'css',xml:'xml',sql:'sql',md:'markdown',
|
||||
@@ -597,58 +624,182 @@
|
||||
go:'go',rs:'rust',rb:'ruby',java:'java',php:'php',c:'cpp',h:'cpp',cpp:'cpp'};
|
||||
function getLang(name){const ext=name.includes('.')?name.split('.').pop().toLowerCase():'';return LANG_MAP[ext]||'plaintext'}
|
||||
|
||||
let _monacoEditor=null;
|
||||
let _monacoOriginalContent='';
|
||||
function openEditor(name,path,content){
|
||||
let _openTabs=[]; // [{path, name, content, originalContent, language, editor, container, modified}]
|
||||
let _activeTab=-1;
|
||||
let _editorVisible=false;
|
||||
let _editorMinimized=false;
|
||||
|
||||
function openFileInEditor(path,name,content){
|
||||
// Check if already open
|
||||
const existing=_openTabs.findIndex(t=>t.path===path);
|
||||
if(existing>=0){switchTab(existing);return}
|
||||
// Add new tab
|
||||
const lang=getLang(name);
|
||||
const isMonaco=!!window._monacoReady;
|
||||
// Resize modal for editor
|
||||
const dialog=document.getElementById('modalDialog');
|
||||
dialog.style.maxWidth=isMonaco?'90vw':'32rem';
|
||||
dialog.style.width=isMonaco?'90vw':'';
|
||||
document.getElementById('monacoContainer').classList.toggle('hidden',!isMonaco);
|
||||
document.getElementById('modalTextarea').classList.toggle('hidden',isMonaco);
|
||||
document.getElementById('editorMeta').classList.remove('hidden');
|
||||
document.getElementById('editorSize').textContent=formatSize(content.length);
|
||||
document.getElementById('editorLang').textContent=lang;
|
||||
document.getElementById('editorModified').classList.add('hidden');
|
||||
if(isMonaco){
|
||||
// Create Monaco editor
|
||||
const container=document.getElementById('monacoContainer');
|
||||
container.innerHTML='';
|
||||
_monacoOriginalContent=content;
|
||||
_monacoEditor=monaco.editor.create(container,{
|
||||
const container=document.createElement('div');
|
||||
container.className='absolute inset-0';
|
||||
container.style.display='none';
|
||||
document.getElementById('editorMonacoWrap').appendChild(container);
|
||||
const tab={path,name,content,originalContent:content,language:lang,editor:null,container,modified:false};
|
||||
_openTabs.push(tab);
|
||||
const idx=_openTabs.length-1;
|
||||
// Create Monaco instance
|
||||
if(window._monacoReady){
|
||||
tab.editor=monaco.editor.create(container,{
|
||||
value:content,language:lang,theme:'vs-dark',
|
||||
automaticLayout:true,minimap:{enabled:false},
|
||||
automaticLayout:true,minimap:{enabled:true,side:'right'},
|
||||
wordWrap:'on',fontSize:13,lineNumbers:'on',
|
||||
scrollBeyondLastLine:false,readOnly:false,
|
||||
bracketPairColorization:{enabled:true},
|
||||
scrollBeyondLastLine:false,bracketPairColorization:{enabled:true},
|
||||
scrollbar:{verticalScrollbarSize:10},
|
||||
});
|
||||
// E-3: Ctrl+S save shortcut
|
||||
_monacoEditor.addCommand(monaco.KeyMod.CtrlCmd|monaco.KeyCode.KeyS,()=>{
|
||||
saveEditorContent(path,name);
|
||||
tab.editor.addCommand(monaco.KeyMod.CtrlCmd|monaco.KeyCode.KeyS,()=>saveCurrentTab());
|
||||
tab.editor.onDidChangeModelContent(()=>{
|
||||
tab.modified=tab.editor.getValue()!==tab.originalContent;
|
||||
updateStatusBar();renderTabBar();
|
||||
});
|
||||
// Track modifications
|
||||
_monacoEditor.onDidChangeModelContent(()=>{
|
||||
const modified=_monacoEditor.getValue()!==_monacoOriginalContent;
|
||||
document.getElementById('editorModified').classList.toggle('hidden',!modified);
|
||||
tab.editor.onDidChangeCursorPosition(e=>{
|
||||
document.getElementById('editorPos').textContent=`Ln ${e.position.lineNumber}, Col ${e.position.column}`;
|
||||
});
|
||||
setTimeout(()=>_monacoEditor.focus(),100);
|
||||
}else{
|
||||
document.getElementById('modalTextarea').value=content;
|
||||
}
|
||||
showModal({
|
||||
title:`📄 ${name}`,monaco:isMonaco,
|
||||
confirmText:'保存',cancelText:'关闭',
|
||||
onConfirm:()=>saveEditorContent(path,name),
|
||||
switchTab(idx);
|
||||
showEditorPanel();
|
||||
renderFileTree();
|
||||
}
|
||||
|
||||
function switchTab(idx){
|
||||
if(idx<0||idx>=_openTabs.length)return;
|
||||
// Hide current
|
||||
if(_activeTab>=0&&_openTabs[_activeTab]){
|
||||
const prev=_openTabs[_activeTab];
|
||||
if(prev.container)prev.container.style.display='none';
|
||||
}
|
||||
_activeTab=idx;
|
||||
const tab=_openTabs[idx];
|
||||
if(tab.container)tab.container.style.display='';
|
||||
if(tab.editor){tab.editor.focus();tab.editor.layout()}
|
||||
updateStatusBar();renderTabBar();renderMinBar();
|
||||
}
|
||||
|
||||
function closeTab(idx){
|
||||
if(idx<0||idx>=_openTabs.length)return;
|
||||
const tab=_openTabs[idx];
|
||||
if(tab.modified&&!confirm(`文件 "${tab.name}" 有未保存的修改,确定关闭?`))return;
|
||||
if(tab.editor)tab.editor.dispose();
|
||||
if(tab.container)tab.container.remove();
|
||||
_openTabs.splice(idx,1);
|
||||
if(_openTabs.length===0){hideEditorPanel();return}
|
||||
if(_activeTab>=_openTabs.length)_activeTab=_openTabs.length-1;
|
||||
switchTab(_activeTab);
|
||||
}
|
||||
|
||||
function closeAllTabs(){
|
||||
const unsaved=_openTabs.filter(t=>t.modified).length;
|
||||
if(unsaved&&!confirm(`有 ${unsaved} 个文件未保存,确定全部关闭?`))return;
|
||||
_openTabs.forEach(t=>{if(t.editor)t.editor.dispose();if(t.container)t.container.remove()});
|
||||
_openTabs=[];_activeTab=-1;
|
||||
hideEditorPanel();
|
||||
}
|
||||
|
||||
function saveCurrentTab(){
|
||||
if(_activeTab<0)return;
|
||||
const tab=_openTabs[_activeTab];
|
||||
const content=tab.editor?tab.editor.getValue():tab.content;
|
||||
if(content===tab.originalContent){toast('info','无变更');return}
|
||||
apiFetch(API+'/sync/write-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path:tab.path,content})}).then(r=>{
|
||||
if(r&&r.ok){toast('success','已保存');tab.originalContent=content;tab.modified=false;_fileCache[tab.path]={content,size:content.length};updateStatusBar();renderTabBar()}
|
||||
else{r?r.json().then(e=>toast('error','保存失败: '+(e.detail||''))).catch(()=>toast('error','保存失败')):toast('error','保存失败')}
|
||||
});
|
||||
}
|
||||
async function saveEditorContent(path,name){
|
||||
const content=_monacoEditor?_monacoEditor.getValue():document.getElementById('modalTextarea').value;
|
||||
if(content===_monacoOriginalContent){toast('info','无变更');return}
|
||||
const wr=await apiFetch(API+'/sync/write-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path,content})});
|
||||
if(wr&&wr.ok){toast('success','文件已保存');_fileCache[path]={content,size:content.length};_monacoOriginalContent=content;document.getElementById('editorModified').classList.add('hidden')}
|
||||
else{const e=wr?await wr.json().catch(()=>({})):{};toast('error','保存失败: '+(e.detail||''))}
|
||||
|
||||
function showEditorPanel(){
|
||||
_editorVisible=true;_editorMinimized=false;
|
||||
document.getElementById('editorPanel').classList.remove('hidden');
|
||||
document.getElementById('editorMinBar').classList.add('hidden');
|
||||
// Trigger layout on active Monaco
|
||||
setTimeout(()=>{if(_activeTab>=0&&_openTabs[_activeTab].editor)_openTabs[_activeTab].editor.layout()},50);
|
||||
}
|
||||
function hideEditorPanel(){
|
||||
_editorVisible=false;
|
||||
document.getElementById('editorPanel').classList.add('hidden');
|
||||
document.getElementById('editorMinBar').classList.add('hidden');
|
||||
renderMinBar();
|
||||
}
|
||||
function minimizeEditor(){
|
||||
_editorMinimized=true;
|
||||
document.getElementById('editorPanel').classList.add('hidden');
|
||||
document.getElementById('editorMinBar').classList.remove('hidden');
|
||||
renderMinBar();
|
||||
}
|
||||
function restoreEditor(){
|
||||
_editorMinimized=false;
|
||||
document.getElementById('editorPanel').classList.remove('hidden');
|
||||
document.getElementById('editorMinBar').classList.add('hidden');
|
||||
if(_activeTab>=0&&_openTabs[_activeTab].editor){setTimeout(()=>_openTabs[_activeTab].editor.layout(),50)}
|
||||
}
|
||||
|
||||
function renderTabBar(){
|
||||
const bar=document.getElementById('editorTabBar');
|
||||
bar.innerHTML=_openTabs.map((t,i)=>{
|
||||
const active=i===_activeTab;
|
||||
const mod=t.modified?'● ':'';
|
||||
return `<div class="flex items-center gap-1 px-3 py-2 text-xs cursor-pointer border-r border-slate-800 shrink-0 ${active?'bg-slate-800 text-white':'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'}" onclick="switchTab(${i})">
|
||||
<span class="${t.modified?'text-amber-400':''}">${mod}${esc(t.name)}</span>
|
||||
<button onclick="event.stopPropagation();closeTab(${i})" class="ml-1 text-slate-600 hover:text-red-400">×</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderMinBar(){
|
||||
const bar=document.getElementById('editorMinTabs');
|
||||
bar.innerHTML=_openTabs.map((t,i)=>{
|
||||
const active=i===_activeTab;
|
||||
return `<div class="flex items-center gap-1 px-2 py-1 text-xs cursor-pointer rounded ${active?'bg-slate-700 text-white':'text-slate-400 hover:text-slate-200'}" onclick="switchTab(${i});restoreEditor()">
|
||||
<span class="${t.modified?'text-amber-400':''}">${t.modified?'● ':''}${esc(t.name)}</span>
|
||||
<button onclick="event.stopPropagation();closeTab(${i})" class="ml-1 text-slate-600 hover:text-red-400">×</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateStatusBar(){
|
||||
if(_activeTab<0)return;
|
||||
const tab=_openTabs[_activeTab];
|
||||
document.getElementById('editorLang').textContent=tab.language;
|
||||
document.getElementById('editorMod').classList.toggle('hidden',!tab.modified);
|
||||
document.getElementById('editorFileName').textContent=tab.path;
|
||||
}
|
||||
|
||||
function renderFileTree(){
|
||||
const list=document.getElementById('editorTreeList');
|
||||
let html=`<div class="px-3 py-1.5 text-xs text-slate-600 cursor-pointer hover:bg-slate-800" onclick="browseDir('${esc(_currentPath.replace(/\/[^/]+\/?$/,'')||'/')}')">📂 ..</div>`;
|
||||
const sorted=sortEntries(_allEntries);
|
||||
sorted.forEach(e=>{
|
||||
const fp=_currentPath.replace(/\/$/,'')+'/'+e.name;
|
||||
if(e.is_dir){
|
||||
html+=`<div class="px-3 py-1 text-xs text-slate-400 cursor-pointer hover:bg-slate-800 truncate" onclick="browseDir('${esc(fp)}')">📁 ${esc(e.name)}</div>`;
|
||||
}else{
|
||||
const fi=fileIcon(e.name,false,e.is_link);
|
||||
const inTab=_openTabs.findIndex(t=>t.path===fp);
|
||||
const active=inTab===_activeTab;
|
||||
html+=`<div class="px-3 py-1 text-xs cursor-pointer hover:bg-slate-800 truncate ${active?'bg-slate-800 text-white':'text-slate-400'}" onclick="openFileFromTree('${esc(fp)}','${esc(e.name)}')">
|
||||
${fi.icon} ${esc(e.name)}${inTab>=0?'<span class="text-brand-light ml-1">●</span>':''}
|
||||
</div>`;
|
||||
}
|
||||
});
|
||||
list.innerHTML=html;
|
||||
}
|
||||
|
||||
async function openFileFromTree(path,name){
|
||||
const cached=_fileCache[path];
|
||||
if(cached){openFileInEditor(path,name,cached.content);return}
|
||||
const r=await apiFetch(API+'/sync/read-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path})});
|
||||
if(r&&r.ok){const d=await r.json();_fileCache[path]={content:d.content,size:d.size};openFileInEditor(path,name,d.content)}
|
||||
else{const e=r?await r.json().catch(()=>({})):{};toast('error','读取失败: '+(e.detail||''))}
|
||||
}
|
||||
|
||||
function toggleEditorTree(){
|
||||
const tree=document.getElementById('editorTree');
|
||||
tree.classList.toggle('hidden');
|
||||
if(_activeTab>=0&&_openTabs[_activeTab].editor)setTimeout(()=>_openTabs[_activeTab].editor.layout(),50);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload (P2-4: progress bar) ──
|
||||
|
||||
Reference in New Issue
Block a user