From a8b32706702c1f18bc8a073c64ddf06321954f24 Mon Sep 17 00:00:00 2001 From: Aisland Date: Tue, 18 Aug 2026 01:14:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E4=BF=9D=E6=B4=BB=E6=B1=A0(=E7=BD=91=E9=A1=B5/MD?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8/=E6=BF=80=E6=B4=BB=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E4=B8=8D=E9=87=8D=E7=BD=AE)=20+=20fix:=20=E6=94=B9=E5=90=8D?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=9C=AC=E5=9C=B0=E8=8D=89=E7=A8=BF(?= =?UTF-8?q?=E5=8F=AF=E5=85=88=E5=88=A0=E5=8E=9F=E5=90=8D,blur=E5=9B=9E?= =?UTF-8?q?=E6=98=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 01_content/src/client/index.tsx | 29 +++- 01_content/src/client/split.tsx | 147 ++++++++++++------ .../worklogs/2026-08-16_worktable-v2.md | 25 +-- 04_test/functional-diag.cjs | 99 +++++++++++- 4 files changed, 232 insertions(+), 68 deletions(-) diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index 82a9190..2013eda 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -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 ( + 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 ?? '📦'}} - renameProject(id, e.target.value)} - /> + renameProject(id, v)} /> diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index 8fc2864..889b40f 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -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>(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 ( +
+ +
+ ) + })} + + ) +} + /** 调试出口(自动化验证用;必须在 store 定义之后) */ try { (window as any).__dshWorktable = { splitStore } } catch {} diff --git a/02_process/worklogs/2026-08-16_worktable-v2.md b/02_process/worklogs/2026-08-16_worktable-v2.md index 9a5bfc4..5a2d0e1 100644 --- a/02_process/worklogs/2026-08-16_worktable-v2.md +++ b/02_process/worklogs/2026-08-16_worktable-v2.md @@ -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 即生效。 diff --git a/04_test/functional-diag.cjs b/04_test/functional-diag.cjs index a2c2f35..32cb2e1 100644 --- a/04_test/functional-diag.cjs +++ b/04_test/functional-diag.cjs @@ -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 {}