feat: 工作区切换保活池(网页/MD滚动/激活标签不重置) + fix: 改名输入本地草稿(可先删原名,blur回显)

This commit is contained in:
Aisland
2026-08-18 01:14:41 +08:00
parent 12cc353964
commit a8b3270670
4 changed files with 232 additions and 68 deletions
+23 -6
View File
@@ -289,6 +289,28 @@ function syncSessionScope(list: any) {
}
}
/** 管理面板改名输入:本地草稿,blur/Enter 才提交(清空时不会立刻弹回原名) */
function RenameInput(props: { initial: string; placeholder: string; onCommit: (v: string) => void }) {
const [val, setVal] = useState(props.initial)
useEffect(() => { setVal(props.initial) }, [props.initial])
const commit = () => {
const v = val
props.onCommit(v)
// 清空提交 = 删除覆盖:回显原名(与文件改名一致)
if (!v.trim()) setVal(props.initial)
}
return (
<input
className="dsh-wt_manageInput"
value={val}
placeholder={props.placeholder}
onChange={(e) => setVal(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === 'Enter') { commit(); (e.target as HTMLInputElement).blur() } }}
/>
)
}
function WorktableSection(props: any) {
const wide = props.wide !== false
const renderProjectSlot = typeof props.renderSlot === 'function' ? props.renderSlot : null
@@ -1115,12 +1137,7 @@ function WorktableSection(props: any) {
title={t('icons.change')}
onClick={(e) => { e.stopPropagation(); openIconPick('project', id, e.currentTarget as HTMLElement) }}
>{projects.iconOverrides[id] ?? meta?.icon ?? '📦'}</span>}
<input
className="dsh-wt_manageInput"
value={display}
placeholder={t('manage.renamePh')}
onChange={(e) => renameProject(id, e.target.value)}
/>
<RenameInput initial={display} placeholder={t('manage.renamePh')} onCommit={(v) => renameProject(id, v)} />
<button type="button" className="dsh-wt_manageBtn" title={isHidden ? t('manage.show') : t('manage.hide')} onClick={() => toggleHidden(id)}>
{isHidden ? '🙈' : '👁'}
</button>
+99 -48
View File
@@ -1352,50 +1352,12 @@ function PanePicker(props: { row: PaneRow; index: number }) {
)
}
/** 分栏工作区浮层(shell.overlay 座位;订阅 splitStore 快照渲染) */
function SplitWorkspace() {
const [snap, setSnap] = useState({
active: splitStore.active,
spec: splitStore.spec,
geom: splitStore.geom,
chatW: splitStore.chatW,
topH: splitStore.topH,
paneWs: [...splitStore.paneWs],
topWs: [...splitStore.topWs],
})
type PoolItem = { spec: LayoutSpec; chatW: number; topH: number; leftW: number; paneWs: number[]; topWs: number[] }
useEffect(() => splitStore.subscribe(() => {
setSnap({
active: splitStore.active,
spec: splitStore.spec,
geom: splitStore.geom,
chatW: splitStore.chatW,
topH: splitStore.topH,
paneWs: [...splitStore.paneWs],
topWs: [...splitStore.topWs],
})
}), [])
useEffect(() => {
if (!snap.active) return
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') splitStore.close()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [snap.active])
// 标签拖放高亮 → 重渲染
const [, setDropTick] = useState(0)
useEffect(() => {
const fn = () => setDropTick((t) => t + 1)
dropTargetListeners.add(fn)
return () => { dropTargetListeners.delete(fn) }
}, [])
if (!snap.active || !snap.spec || !snap.geom) return null
const g = snap.geom
const spec = snap.spec
/** 单个工作区渲染层(geom 为 null 时用 0 几何渲染,外层 display:none 保活,保留网页/MD 滚动/激活标签等状态) */
function WorkspaceLayer(props: { spec: LayoutSpec; geom: Geom | null; chatW: number; topH: number; leftW: number; paneWs: number[]; topWs: number[] }) {
const g = props.geom ?? { left: 0, top: 0, right: 0, bottom: 0 }
const spec = props.spec
const top = spec.top ?? []
const main = spec.main ?? []
const hasLeft = !!spec.left
@@ -1403,12 +1365,12 @@ function SplitWorkspace() {
const chatLeft = !hasLeft && spec.chatSide === 'left'
const colW = g.right - g.left
const rowH = g.bottom - g.top
const chatW = clamp(snap.chatW, spec.chatWidth.min, Math.max(spec.chatWidth.min, colW - 60))
const chatW = clamp(props.chatW, spec.chatWidth.min, Math.max(spec.chatWidth.min, colW - 60))
const topH = hasTop
? clamp(snap.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80))
? clamp(props.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80))
: 0
const leftW = hasLeft
? clamp(snap.leftW, spec.leftWidth?.min ?? 160, Math.max(spec.leftWidth?.min ?? 160, colW - 260))
? clamp(props.leftW, spec.leftWidth?.min ?? 160, Math.max(spec.leftWidth?.min ?? 160, colW - 260))
: 0
const chatFull = spec.chatFullHeight === true
const contentW = Math.max(0, colW - chatW)
@@ -1416,8 +1378,8 @@ function SplitWorkspace() {
const topRowX = hasLeft ? g.left + leftW : contentX
const topRowW = hasLeft ? Math.max(0, colW - leftW) : (chatFull ? contentW : colW)
const topItems = allocate(top, snap.topWs, topRowW)
const mainItems = allocate(main, snap.paneWs, contentW)
const topItems = allocate(top, props.topWs, topRowW)
const mainItems = allocate(main, props.paneWs, contentW)
const leftItem = spec.left ? { pane: spec.left, left: 0, width: leftW } : null
const barTop = g.top
@@ -1542,6 +1504,95 @@ function SplitWorkspace() {
)
}
/** 分栏工作区浮层(shell.overlay 座位;订阅 splitStore 快照渲染)。
* 切换项目时旧工作区不销毁:全部挂载在池中、仅当前可见(display:none 保活),
* 网页子页面/滚动位置、MD 滚动位置、激活标签等在切回时原样保留。 */
function SplitWorkspace() {
const [snap, setSnap] = useState({
active: splitStore.active,
spec: splitStore.spec,
geom: splitStore.geom,
chatW: splitStore.chatW,
topH: splitStore.topH,
leftW: splitStore.leftW,
paneWs: [...splitStore.paneWs],
topWs: [...splitStore.topWs],
})
const poolRef = useRef<Map<string, PoolItem>>(new Map())
const [, setPoolTick] = useState(0)
useEffect(() => splitStore.subscribe(() => {
const spec = splitStore.spec
if (splitStore.active && spec) {
poolRef.current.set(spec.id, {
spec,
chatW: splitStore.chatW,
topH: splitStore.topH,
leftW: splitStore.leftW,
paneWs: [...splitStore.paneWs],
topWs: [...splitStore.topWs],
})
// 保活池上限 6 个(LRU:删最老的),避免长时间使用内存膨胀
while (poolRef.current.size > 6) {
const first = poolRef.current.keys().next().value
if (first != null) poolRef.current.delete(first)
}
}
setSnap({
active: splitStore.active,
spec: splitStore.spec,
geom: splitStore.geom,
chatW: splitStore.chatW,
topH: splitStore.topH,
leftW: splitStore.leftW,
paneWs: [...splitStore.paneWs],
topWs: [...splitStore.topWs],
})
setPoolTick((t) => t + 1)
}), [])
useEffect(() => {
if (!snap.active) return
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') splitStore.close()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [snap.active])
// 标签拖放高亮 → 重渲染
const [, setDropTick] = useState(0)
useEffect(() => {
const fn = () => setDropTick((t) => t + 1)
dropTargetListeners.add(fn)
return () => { dropTargetListeners.delete(fn) }
}, [])
const activeId = snap.active && snap.spec ? snap.spec.id : null
const entries = Array.from(poolRef.current.entries())
if (entries.length === 0) return null
return (
<>
{entries.map(([id, item]) => {
const isActive = id === activeId
return (
<div key={id} style={isActive ? undefined : { display: 'none' }}>
<WorkspaceLayer
spec={item.spec}
geom={isActive ? snap.geom : null}
chatW={isActive ? snap.chatW : item.chatW}
topH={isActive ? snap.topH : item.topH}
leftW={isActive ? snap.leftW : item.leftW}
paneWs={isActive ? snap.paneWs : item.paneWs}
topWs={isActive ? snap.topWs : item.topWs}
/>
</div>
)
})}
</>
)
}
/** 调试出口(自动化验证用;必须在 store 定义之后) */
try { (window as any).__dshWorktable = { splitStore } } catch {}
+14 -11
View File
@@ -639,16 +639,19 @@
原生视图内部是浏览器扩展页,插件注入不了按钮、拦不到鼠标,也驱动不了内部滚动——
向用户如实说明后按指示回退);② 视图选项改名「设置」,点开直接内嵌「排序方式 + 管理项目
展开列表」,不再二次点击;③ 管理项目里给现有项目加「变更视图」:换预设拓扑,
## 补记(txt/tsx/css 编辑开启 + 代码语法着色)
## 补记(工作区切换保活 + 改名输入草稿修复)
- 用户需求:① txt/tsx/css 也开编辑模式;② 代码文件要有颜色(考虑开源后程序员使用)。
- 用户反馈:① 切到其他项目再回来,网页子页面/位置、MD 滚动位置、激活标签全部重置;
② 改名时先删原名会立刻弹回(只有先加新字才能删)。
- 处理:
① TextViewer 编辑/预览工具栏对所有文本类型通用(md/txt/log/tsx/ts/jsx/js/css/json),
保存走既有 /api/worktable/write 路由;
② 语法着色:highlight.js(只打包 core + typescript/javascript/css/json 四种语言),
代码预览渲染成 .dsh-wt_code(token class 配色采用 GitHub Dark 风格自写 CSS,无主题依赖);
无语言映射时 HTML 转义兜底;bundle 859KB。
- 验证(functional-diag):STEP15 tsx 预览 hljs-keyword 493 个 token + 编辑区出现/含原文/切回预览;
STEP11 package.json 改断言 json 高亮 31 个 token;其余全绿 ERROR_COUNT: 0;
STEP16 写路由仍 writeRouteReady=false(等用户重启后验证保存闭环)。
- 备注:客户端 F5 生效;保存写盘需重启一次 dsh web(上次已提示)。
① SplitWorkspace 重构为「保活池」:按布局 id 把工作区层全部保持挂载(display:none 隐藏、
仅当前可见;池上限 6 个 LRU)。切走再切回时 iframe 实例不销毁(子页面/滚动保留)、
TextViewer 滚动保留、激活标签保留;几何经 props 传入(隐藏层用 0 几何)。
重构中清理了 snap.leftW 长期缺省(clamp(undefined)→NaN)的隐患;
② 改名输入改本地草稿(RenameInput 组件):输入不落盘,blur/Enter 才提交;
清空提交 = 删除覆盖并回显原名。Enter 直接调 commit(无头实测发现程序化 blur() 不派发
focusout,真实点击无碍,但 Enter 路径必须显式提交)。
- 验证(functional-diag STEP17/18 + 多个探针):STEP17 scrollBeforeClose=320 →
scrollAfter=320、activeAfter=1、iframeSameRef=true;STEP18 cleared=true 且真实 CDP 点击
失焦后 revertedToOriginal=true;全 18 STEP ERROR_COUNT: 0。
- 备注:纯客户端改动,F5 即生效。
+96 -3
View File
@@ -282,9 +282,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
st.openTab('main',0,{kind:'file',path:'E:\\\\AI_Workspace\\\\DeepseekHarness\\\\Projects\\\\dsh-worktable\\\\01_content\\\\src\\\\client\\\\index.tsx'});
await new Promise(function(r){setTimeout(r,1200)});
out.tsxTab=st.spec.main[0].tabs[0].title;
out.tsxView=!!document.querySelector('.dsh-wt_code');
out.tsxHljs=document.querySelectorAll('.dsh-wt_code .hljs-keyword').length;
out.tsxHasText=document.querySelector('.dsh-wt_code')?String(document.querySelector('.dsh-wt_code').textContent).indexOf('WorktableSection')>=0:false;
var codeEl=[].slice.call(document.querySelectorAll('.dsh-wt_code')).find(function(el){return el.getBoundingClientRect().height>0});
out.tsxView=!!codeEl;
out.tsxHljs=codeEl?codeEl.querySelectorAll('.hljs-keyword').length:0;
out.tsxHasText=codeEl?String(codeEl.textContent).indexOf('WorktableSection')>=0:false;
st.openTab('main',0,{kind:'file',path:'E:\\\\AI_Workspace\\\\DeepseekHarness\\\\Projects\\\\dsh-worktable\\\\01_content\\\\src\\\\client\\\\styles.ts'});
await new Promise(function(r){setTimeout(r,900)});
out.cssTab=st.spec.main[0].tabs[1].title;
@@ -510,6 +511,98 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
})()`);
console.log('STEP16:', JSON.stringify(step16));
// 工作区状态保活:切换项目后激活标签/MD滚动位置/iframe 实例原样保留
const step17 = await evaluate(`(async function(){
var out={};
try{
var st=window.__dshWorktable.splitStore;
var spec={id:'t-pool',title:'pool',top:null,main:[{id:'p1',title:'p',min:200,content:null}],chatWidth:{default:320,min:240,max:600}};
st.open(spec);
await new Promise(function(r){setTimeout(r,400)});
st.openTab('main',0,{kind:'file',path:'E:\\\\AI_Workspace\\\\DeepseekHarness\\\\Projects\\\\dsh-worktable\\\\02_process\\\\PRD.md'});
await new Promise(function(r){setTimeout(r,900)});
st.openTab('main',0,{kind:'file',path:'E:\\\\AI_Workspace\\\\DeepseekHarness\\\\Projects\\\\dsh-worktable\\\\01_content\\\\src\\\\client\\\\styles.ts'});
await new Promise(function(r){setTimeout(r,900)});
st.setActiveTab('main',0,st.spec.main[0].tabs[1].id);
// 轮询等待代码视图渲染(fetch 完成后 .dsh-wt_fileView 才出现;只取可见元素,避开保活池里的隐藏层)
var fv=null;
for(var w=0;w<20 && !fv;w++){ await new Promise(function(r){setTimeout(r,150)}); fv=[].slice.call(document.querySelectorAll('.dsh-wt_fileView')).find(function(el){return el.getBoundingClientRect().height>0}); }
out.fileViewReady=!!fv;
if(fv){ fv.scrollTop=320; }
await new Promise(function(r){setTimeout(r,200)});
out.scrollBeforeClose=fv?fv.scrollTop:null;
var savedSpec=st.spec;
st.close();
await new Promise(function(r){setTimeout(r,300)});
st.open(savedSpec);
await new Promise(function(r){setTimeout(r,500)});
out.activeAfter=st.spec.main[0].active;
var fv2=[].slice.call(document.querySelectorAll('.dsh-wt_fileView')).find(function(el){return el.getBoundingClientRect().height>0});
out.scrollAfter=fv2?fv2.scrollTop:null;
// iframe 保活:加一个站点 iframe 标签 → 关闭 → 重开 → 同一 DOM 实例
var rootToken=encodeURIComponent('E:\\\\AI_Workspace\\\\DeepseekHarness\\\\Projects\\\\dsh-worktable\\\\04_test\\\\fixture-site');
st.openTab('main',0,{kind:'iframe',url:'/api/worktable/site/'+rootToken+'/index.html',title:'site'});
await new Promise(function(r){setTimeout(r,1500)});
var f1=document.querySelector('.dsh-wt_paneFrame');
var savedSpec2=st.spec;
st.close();
await new Promise(function(r){setTimeout(r,300)});
st.open(savedSpec2);
await new Promise(function(r){setTimeout(r,500)});
var f2=document.querySelector('.dsh-wt_paneFrame');
out.iframeSameRef=f1===f2;
out.activeAfter2=st.spec.main[0].active;
st.close();
await new Promise(function(r){setTimeout(r,200)});
}catch(err){ out.err=String(err) }
return JSON.stringify(out);
})()`);
console.log('STEP17:', JSON.stringify(step17));
// 改名输入框:清空不会立刻弹回原名(本地草稿);真实鼠标点击别处触发 blur 提交后回显原名
const step18pre = await evaluate(`(async function(){
var out={};
try{
var btn=document.querySelector('.dsh-wt_actions .dsh-wt_iconBtn:nth-child(2)');
if(btn){ btn.click(); }
await new Promise(function(r){setTimeout(r,300)});
var rows=document.querySelectorAll('.dsh-wt_settings .dsh-wt_manageRow:not(.dsh-wt_manageRowSc):not(.dsh-wt_manageRowRemoved)');
var inp=rows[0]?rows[0].querySelector('.dsh-wt_manageInput'):null;
if(inp){
var setter=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;
setter.call(inp,'');
inp.dispatchEvent(new Event('input',{bubbles:true}));
await new Promise(function(r){setTimeout(r,250)});
out.cleared=inp.value==='';
inp.focus();
await new Promise(function(r){setTimeout(r,100)});
}
var sr=document.querySelector('.dsh-wt_sortRow').getBoundingClientRect();
out.x=Math.round(sr.left+10);
out.y=Math.round(sr.top+10);
}catch(err){ out.err=String(err) }
return JSON.stringify(out);
})()`);
console.log('STEP18pre:', JSON.stringify(step18pre));
const p18 = JSON.parse(step18pre);
if (p18 && p18.x != null && p18.y != null) {
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: p18.x, y: p18.y, button: 'left', clickCount: 1 });
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: p18.x, y: p18.y, button: 'left', clickCount: 1 });
await sleep(300);
}
const step18 = await evaluate(`(function(){
var out={};
try{
var rows=document.querySelectorAll('.dsh-wt_settings .dsh-wt_manageRow:not(.dsh-wt_manageRowSc):not(.dsh-wt_manageRowRemoved)');
var inp=rows[0]?rows[0].querySelector('.dsh-wt_manageInput'):null;
out.revertedToOriginal=inp?inp.value!=='':false;
var bd=document.querySelector('.dsh-wt_popBackdrop');
if(bd){ bd.click(); }
}catch(err){ out.err=String(err) }
return JSON.stringify(out);
})()`);
console.log('STEP18:', JSON.stringify(step18));
// 还原临时夹具
try { fs.writeFileSync(TEMP_MD, '# ORIG\n', 'utf8') } catch {}