From 1638199ff7b77f2b05a8a52622bc278de4ceb573 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Tue, 31 Mar 2026 10:25:55 +0000 Subject: [PATCH 1/2] fix: address codexmate review follow-ups --- README.en.md | 6 ++++-- site/guide/getting-started.md | 4 +++- tests/unit/web-run-host.test.mjs | 3 ++- web-ui/modules/skills.methods.mjs | 7 ++++--- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/README.en.md b/README.en.md index fb35085..0de115f 100644 --- a/README.en.md +++ b/README.en.md @@ -124,7 +124,9 @@ codexmate status codexmate run ``` -Default listen address is `0.0.0.0:3737` for LAN access, and browser auto-open is enabled by default. +Default listen address is `127.0.0.1:3737`, localhost-only, and browser auto-open is enabled by default. + +> Security note: if you explicitly use `--host 0.0.0.0` or set `CODEXMATE_HOST=0.0.0.0`, the unauthenticated admin UI becomes reachable on your current network. If the instance contains API keys, provider settings, or skills management data, prefer `127.0.0.1` and only enable LAN access on trusted networks. ### Run from source @@ -232,7 +234,7 @@ codexmate mcp serve --allow-write | Variable | Default | Description | | --- | --- | --- | | `CODEXMATE_PORT` | `3737` | Web server port | -| `CODEXMATE_HOST` | `0.0.0.0` | Web listen host | +| `CODEXMATE_HOST` | `127.0.0.1` | Web listen host (set `0.0.0.0` explicitly for LAN access) | | `CODEXMATE_NO_BROWSER` | unset | Set `1` to disable browser auto-open | | `CODEXMATE_MCP_ALLOW_WRITE` | unset | Set `1` to allow MCP write tools by default | | `CODEXMATE_FORCE_RESET_EXISTING_CONFIG` | `0` | Set `1` to force bootstrap reset of existing config | diff --git a/site/guide/getting-started.md b/site/guide/getting-started.md index 77c8c38..fa50d1d 100644 --- a/site/guide/getting-started.md +++ b/site/guide/getting-started.md @@ -27,7 +27,9 @@ codexmate status codexmate run ``` -默认监听 `0.0.0.0:3737`,支持局域网访问,并尝试自动打开浏览器。 +默认监听 `127.0.0.1:3737`,仅本机可访问,并尝试自动打开浏览器。 + +如需局域网访问,可显式设置 `CODEXMATE_HOST=0.0.0.0` 或使用 `--host 0.0.0.0`;若当前网络不可信,建议继续使用 `127.0.0.1`。 仅启动服务(测试 / CI): diff --git a/tests/unit/web-run-host.test.mjs b/tests/unit/web-run-host.test.mjs index 27ae2be..c50f3d3 100644 --- a/tests/unit/web-run-host.test.mjs +++ b/tests/unit/web-run-host.test.mjs @@ -109,7 +109,7 @@ function instantiateFunction(funcSource, funcName, bindings = {}) { return Function(...bindingNames, `${funcSource}\nreturn ${funcName};`)(...bindingValues); } -const defaultHostMatch = cliContent.match(/const DEFAULT_WEB_HOST = '([^']+)';/); +const defaultHostMatch = cliContent.match(/const\s+DEFAULT_WEB_HOST\s*=\s*['\"]([^'\"]+)['\"]\s*;?/); if (!defaultHostMatch) { throw new Error('DEFAULT_WEB_HOST not found'); } @@ -198,6 +198,7 @@ test('resolveSkillTarget still falls back to default target when target is omitt test('resolveSkillTarget rejects explicit unsupported targets instead of falling back', () => { assert.strictEqual(resolveSkillTarget({ targetApp: 'claud' }), null); + assert.strictEqual(resolveSkillTarget({ target: 'claud' }), null); assert.strictEqual(resolveSkillTarget({ target: 'unknown' }, 'codex'), null); }); diff --git a/web-ui/modules/skills.methods.mjs b/web-ui/modules/skills.methods.mjs index 6b53d77..13c52d9 100644 --- a/web-ui/modules/skills.methods.mjs +++ b/web-ui/modules/skills.methods.mjs @@ -35,10 +35,11 @@ async openSkillsManager(options = {}) { const targetApp = this.normalizeSkillsTargetApp(options && options.targetApp ? options.targetApp : this.skillsTargetApp); - if (targetApp !== this.skillsTargetApp) { + const targetChanged = targetApp !== this.skillsTargetApp; + if (targetChanged) { this.skillsTargetApp = targetApp; + this.resetSkillsTargetState(); } - this.resetSkillsTargetState(); this.showSkillsModal = true; await this.refreshSkillsList({ silent: false }); }, @@ -158,7 +159,7 @@ }, async scanImportableSkills(options = {}) { - if (this.skillsScanningImports || this.skillsImporting || this.skillsZipImporting || this.skillsExporting) return; + if (this.skillsScanningImports || this.skillsImporting || this.skillsZipImporting || this.skillsExporting) return false; const silent = !!(options && options.silent); this.skillsScanningImports = true; try { From 49c6da1116943272646880863fe69e87553bf883 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Fri, 3 Apr 2026 09:57:20 +0000 Subject: [PATCH 2/2] refactor(web-ui): split session trash methods out of app --- tests/unit/config-tabs-ui.test.mjs | 33 +- tests/unit/session-trash-state.test.mjs | 444 ++++++++++------------- web-ui/app.js | 362 +----------------- web-ui/modules/session-trash.methods.mjs | 380 +++++++++++++++++++ 4 files changed, 601 insertions(+), 618 deletions(-) create mode 100644 web-ui/modules/session-trash.methods.mjs diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index 7755f5e..d3d7d71 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -109,6 +109,7 @@ test('config template keeps expected config tabs in top and side navigation', () test('web ui script defines provider mode metadata for codex only', () => { const appScript = readProjectFile('web-ui/app.js'); const configModeComputed = readProjectFile('web-ui/modules/config-mode.computed.mjs'); + const sessionTrashMethods = readProjectFile('web-ui/modules/session-trash.methods.mjs'); assert.match(appScript, /CONFIG_MODE_SET/); assert.match(appScript, /getProviderConfigModeMeta/); @@ -176,6 +177,8 @@ test('web ui script defines provider mode metadata for codex only', () => { assert.match(appScript, /skillsMarketLoading:\s*false/); assert.match(appScript, /skillsMarketLocalLoadedOnce:\s*false/); assert.match(appScript, /skillsMarketImportLoadedOnce:\s*false/); + assert.match(appScript, /createSessionTrashMethods/); + assert.match(appScript, /\.\.\.createSessionTrashMethods\(/); assert.doesNotMatch(appScript, /skillsMarketRemoteLoading:\s*false/); assert.doesNotMatch(appScript, /skillsMarketRemoteLoadedOnce:\s*false/); assert.doesNotMatch(appScript, /skillsMarketRemoteItems:\s*\[\]/); @@ -190,21 +193,21 @@ test('web ui script defines provider mode metadata for codex only', () => { assert.match(appScript, /visibleSessionTrashItems\(\)/); assert.match(appScript, /sessionTrashHasMoreItems\(\)/); assert.match(appScript, /sessionTrashHiddenCount\(\)/); - assert.match(appScript, /normalizeSettingsTab\(tab\)/); - assert.match(appScript, /switchSettingsTab\(tab,\s*options = \{\}\)/); - assert.match(appScript, /loadSessionTrash\(options = \{\}\)/); - assert.match(appScript, /loadMoreSessionTrashItems\(\)/); - assert.match(appScript, /restoreSessionTrash\(item\)/); - assert.match(appScript, /purgeSessionTrash\(item\)/); - assert.match(appScript, /clearSessionTrash\(\)/); - assert.match(appScript, /buildSessionTrashItemFromSession\(session,\s*result = \{\}\)/); - assert.match(appScript, /prependSessionTrashItem\(item,\s*options = \{\}\)/); - assert.match(appScript, /resetSessionTrashVisibleCount\(\)/); - assert.match(appScript, /normalizeSessionTrashTotalCount\(totalCount,\s*fallbackItems = this\.sessionTrashItems\)/); - assert.match(appScript, /getSessionTrashViewState\(\)/); - assert.match(appScript, /this\.sessionTrashTotalCount = this\.normalizeSessionTrashTotalCount\(res\.totalCount,\s*nextItems\);/); - assert.match(appScript, /this\.sessionTrashTotalCount = this\.normalizeSessionTrashTotalCount\(\s*res && res\.totalCount !== undefined/); - assert.match(appScript, /messageCount:\s*Number\.isFinite\(Number\(result && result\.messageCount\)\)/); + assert.match(sessionTrashMethods, /normalizeSettingsTab\(tab\)/); + assert.match(sessionTrashMethods, /switchSettingsTab\(tab,\s*options = \{\}\)/); + assert.match(sessionTrashMethods, /loadSessionTrash\(options = \{\}\)/); + assert.match(sessionTrashMethods, /loadMoreSessionTrashItems\(\)/); + assert.match(sessionTrashMethods, /restoreSessionTrash\(item\)/); + assert.match(sessionTrashMethods, /purgeSessionTrash\(item\)/); + assert.match(sessionTrashMethods, /clearSessionTrash\(\)/); + assert.match(sessionTrashMethods, /buildSessionTrashItemFromSession\(session,\s*result = \{\}\)/); + assert.match(sessionTrashMethods, /prependSessionTrashItem\(item,\s*options = \{\}\)/); + assert.match(sessionTrashMethods, /resetSessionTrashVisibleCount\(\)/); + assert.match(sessionTrashMethods, /normalizeSessionTrashTotalCount\(totalCount,\s*fallbackItems = this\.sessionTrashItems\)/); + assert.match(sessionTrashMethods, /getSessionTrashViewState\(\)/); + assert.match(sessionTrashMethods, /this\.sessionTrashTotalCount = this\.normalizeSessionTrashTotalCount\(res\.totalCount,\s*nextItems\);/); + assert.match(sessionTrashMethods, /options && options\.totalCount !== undefined/); + assert.match(sessionTrashMethods, /messageCount:\s*Number\.isFinite\(Number\(result && result\.messageCount\)\)/); assert.match(appScript, /clearActiveSessionState\(\)/); assert.match(appScript, /removeSessionFromCurrentList\(session\)/); assert.match(appScript, /await this\.removeSessionFromCurrentList\(session\);/); diff --git a/tests/unit/session-trash-state.test.mjs b/tests/unit/session-trash-state.test.mjs index eaebd90..ffeb682 100644 --- a/tests/unit/session-trash-state.test.mjs +++ b/tests/unit/session-trash-state.test.mjs @@ -1,7 +1,7 @@ -import assert from 'assert'; +import assert from 'assert'; import fs from 'fs'; import path from 'path'; -import { fileURLToPath } from 'url'; +import { fileURLToPath, pathToFileURL } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -15,6 +15,19 @@ const cliSource = fs.readFileSync(cliPath, 'utf-8'); const indexHtmlSource = fs.readFileSync(indexHtmlPath, 'utf-8'); const stylesSource = fs.readFileSync(stylesPath, 'utf-8'); +const sessionTrashModule = await import(pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'session-trash.methods.mjs')).href); +const { + createSessionTrashMethods, + resolveSessionTrashListLimit, + resolveSessionTrashPageSize, + DEFAULT_SESSION_TRASH_LIST_LIMIT, + DEFAULT_SESSION_TRASH_PAGE_SIZE +} = sessionTrashModule; + +function createSessionTrashTestMethods(api, constants = {}) { + return createSessionTrashMethods({ api, constants }); +} + function escapeRegExp(value) { return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } @@ -219,35 +232,26 @@ test('buildClaudeSessionIndexEntry prefers normalized metadata when stored index } }) }, - path: { - dirname: () => '/tmp/claude-project' - } + path }); - const result = buildClaudeSessionIndexEntry({ - source: 'claude', - sessionId: 'claude-missing-index', - title: 'missing index entry', - provider: 'claude', - capabilities: { code: true }, - keywords: ['claude_code'], + const entry = buildClaudeSessionIndexEntry({ + sessionId: 'session-1', + cwd: '/tmp/project', messageCount: 7, - createdAt: '2025-03-01T00:00:00.000Z', - updatedAt: '2025-03-01T00:00:07.000Z', - claudeIndexEntry: { - messageCount: 2, - capabilities: {}, - keywords: [] - } - }, '/tmp/claude-project/claude-missing-index.jsonl'); + title: 'Claude session', + capabilities: { edit: true }, + keywords: ['claude_code'], + updatedAt: '2025-03-29T00:00:00.000Z', + createdAt: '2025-03-28T00:00:00.000Z' + }, '/tmp/session-1.jsonl'); - assert.strictEqual(result.messageCount, 8); - assert.deepStrictEqual(result.capabilities, { code: true }); - assert.deepStrictEqual(result.keywords, ['claude_code']); + assert.strictEqual(entry.fullPath, '/tmp/session-1.jsonl'); + assert.strictEqual(entry.messageCount, 8); + assert.strictEqual(entry.modified, '2025-03-30T00:00:00.000Z'); }); test('resolveClaudeSessionRestoreIndexPath ignores untrusted stored index path', () => { - const posixPath = path.posix; const resolveClaudeSessionRestoreIndexPathSource = extractFunctionBySignature( cliSource, 'function resolveClaudeSessionRestoreIndexPath(entry, targetFilePath) {', @@ -257,208 +261,197 @@ test('resolveClaudeSessionRestoreIndexPath ignores untrusted stored index path', resolveClaudeSessionRestoreIndexPathSource, 'resolveClaudeSessionRestoreIndexPath', { - findClaudeSessionIndexPath(targetFilePath) { - return posixPath.join(posixPath.dirname(targetFilePath), 'sessions-index.json'); + findClaudeSessionIndexPath(targetPath) { + return targetPath === '/tmp/sessions/target.jsonl' ? '/tmp/sessions-index.json' : ''; }, getClaudeProjectsDir() { - return '/tmp/claude-projects'; + return '/tmp'; }, - isPathInside(targetPath, rootPath) { - const resolvedTarget = posixPath.resolve(targetPath); - const resolvedRoot = posixPath.resolve(rootPath); - return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${posixPath.sep}`); + isPathInside(candidatePath, rootPath) { + const rel = path.relative(rootPath, candidatePath); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); }, - path: posixPath + path } ); - const fallbackPath = posixPath.join('/tmp/claude-projects/project-a', 'sessions-index.json'); - const targetFilePath = '/tmp/claude-projects/project-a/session.jsonl'; - - assert.strictEqual( - resolveClaudeSessionRestoreIndexPath({ claudeIndexPath: '/tmp/outside/sessions-index.json' }, targetFilePath), - fallbackPath - ); assert.strictEqual( - resolveClaudeSessionRestoreIndexPath({ claudeIndexPath: '/tmp/claude-projects/project-b/sessions-index.json' }, targetFilePath), - fallbackPath - ); - assert.strictEqual( - resolveClaudeSessionRestoreIndexPath({ claudeIndexPath: fallbackPath }, targetFilePath), - fallbackPath + resolveClaudeSessionRestoreIndexPath({ claudeIndexPath: '/tmp/evil-index.json' }, '/tmp/sessions/target.jsonl'), + '/tmp/sessions-index.json' ); }); test('removeClaudeSessionIndexEntry keeps differently cased paths distinct on case-sensitive platforms', () => { - let writePayload = null; const removeClaudeSessionIndexEntrySource = extractFunctionBySignature( cliSource, 'function removeClaudeSessionIndexEntry(indexPath, sessionFilePath, sessionId) {', 'removeClaudeSessionIndexEntry' ); + let writtenIndex = null; const removeClaudeSessionIndexEntry = instantiateFunction( removeClaudeSessionIndexEntrySource, 'removeClaudeSessionIndexEntry', { fs: { - existsSync: () => true + existsSync() { + return true; + } }, readJsonFile() { return { - entries: [{ - sessionId: 'case-sensitive-entry', - fullPath: '/tmp/Session.jsonl' - }] + entries: [ + { sessionId: 'same-id', fullPath: '/tmp/Session.jsonl', note: 'keep-case-sensitive' }, + { sessionId: 'same-id', fullPath: '/tmp/session.jsonl', note: 'remove-target' }, + { sessionId: 'other-id', fullPath: '/tmp/other.jsonl', note: 'keep-other' } + ], + originalPath: '/tmp' }; }, writeJsonAtomic(indexPath, payload) { - writePayload = { indexPath, payload }; + writtenIndex = { indexPath, payload }; }, - expandHomePath(value) { - return value; + expandHomePath(targetPath) { + return targetPath; }, - normalizePathForCompare(targetPath, options = {}) { - const resolved = path.resolve(targetPath); - return options.ignoreCase ? resolved.toLowerCase() : resolved; + normalizePathForCompare(targetPath, { ignoreCase } = {}) { + const normalized = path.resolve(targetPath); + return ignoreCase ? normalized.toLowerCase() : normalized; }, path, - process: { platform: 'linux' }, - JSON + process: { platform: 'linux' } } ); - const result = removeClaudeSessionIndexEntry('/tmp/sessions-index.json', '/tmp/session.jsonl', 'missing'); + removeClaudeSessionIndexEntry('/tmp/sessions-index.json', '/tmp/session.jsonl', 'same-id'); - assert.deepStrictEqual(result, { removed: false, entry: null }); - assert.strictEqual(writePayload, null); + assert.deepStrictEqual(writtenIndex, { + indexPath: '/tmp/sessions-index.json', + payload: { + entries: [ + { sessionId: 'same-id', fullPath: '/tmp/Session.jsonl', note: 'keep-case-sensitive' }, + { sessionId: 'other-id', fullPath: '/tmp/other.jsonl', note: 'keep-other' } + ], + originalPath: '/tmp' + } + }); }); test('removeClaudeSessionIndexEntry prefers normalized fullPath over stale sessionId when file path is known', () => { - let writePayload = null; const removeClaudeSessionIndexEntrySource = extractFunctionBySignature( cliSource, 'function removeClaudeSessionIndexEntry(indexPath, sessionFilePath, sessionId) {', 'removeClaudeSessionIndexEntry' ); + let writtenIndex = null; const removeClaudeSessionIndexEntry = instantiateFunction( removeClaudeSessionIndexEntrySource, 'removeClaudeSessionIndexEntry', { fs: { - existsSync: () => true + existsSync() { + return true; + } }, readJsonFile() { return { entries: [ - { - sessionId: 'stale-id', - fullPath: '/tmp/other.jsonl', - note: 'keep-by-path' - }, - { - sessionId: 'fresh-id', - fullPath: '/tmp/session.jsonl', - note: 'remove-by-path' - } - ] + { sessionId: 'stale-id', fullPath: '/tmp/session.jsonl', note: 'remove-target' }, + { sessionId: 'stale-id', fullPath: '/tmp/other.jsonl', note: 'keep-by-path' } + ], + originalPath: '/tmp' }; }, writeJsonAtomic(indexPath, payload) { - writePayload = { indexPath, payload }; + writtenIndex = { indexPath, payload }; }, - expandHomePath(value) { - return value; + expandHomePath(targetPath) { + return targetPath; }, - normalizePathForCompare(targetPath, options = {}) { - const resolved = path.resolve(targetPath); - return options.ignoreCase ? resolved.toLowerCase() : resolved; + normalizePathForCompare(targetPath, { ignoreCase } = {}) { + const normalized = path.resolve(targetPath); + return ignoreCase ? normalized.toLowerCase() : normalized; }, path, - process: { platform: 'linux' }, - JSON + process: { platform: 'linux' } } ); - const result = removeClaudeSessionIndexEntry('/tmp/sessions-index.json', '/tmp/session.jsonl', 'stale-id'); + removeClaudeSessionIndexEntry('/tmp/sessions-index.json', '/tmp/session.jsonl', 'stale-id'); - assert.deepStrictEqual(result, { - removed: true, - entry: { - sessionId: 'fresh-id', - fullPath: '/tmp/session.jsonl', - note: 'remove-by-path' - } - }); - assert.deepStrictEqual(writePayload, { + assert.deepStrictEqual(writtenIndex, { indexPath: '/tmp/sessions-index.json', payload: { - entries: [{ - sessionId: 'stale-id', - fullPath: '/tmp/other.jsonl', - note: 'keep-by-path' - }] + entries: [ + { sessionId: 'stale-id', fullPath: '/tmp/other.jsonl', note: 'keep-by-path' } + ], + originalPath: '/tmp' } }); }); test('upsertClaudeSessionIndexEntry keeps differently cased paths distinct on case-sensitive platforms', () => { - let writtenIndex = null; const upsertClaudeSessionIndexEntrySource = extractFunctionBySignature( cliSource, 'function upsertClaudeSessionIndexEntry(indexPath, sessionFilePath, entry) {', 'upsertClaudeSessionIndexEntry' ); + let writtenIndex = null; const upsertClaudeSessionIndexEntry = instantiateFunction( upsertClaudeSessionIndexEntrySource, 'upsertClaudeSessionIndexEntry', { readJsonFile() { return { - entries: [{ - sessionId: 'case-sensitive-entry', - fullPath: '/tmp/Session.jsonl' - }] + entries: [ + { sessionId: 'stale-id', fullPath: '/tmp/Session.jsonl', note: 'keep-case-sensitive' }, + { sessionId: 'stale-id', fullPath: '/tmp/other.jsonl', note: 'keep-other' } + ], + originalPath: '/tmp' }; }, - normalizePathForCompare(targetPath, options = {}) { - const resolved = path.resolve(targetPath); - return options.ignoreCase ? resolved.toLowerCase() : resolved; + writeJsonAtomic(indexPath, payload) { + writtenIndex = { indexPath, payload }; }, normalizeSessionTrashEntry(entry) { return entry; }, - buildClaudeSessionIndexEntry(entry, sessionFilePath) { - return { - sessionId: entry.sessionId, - fullPath: sessionFilePath - }; + normalizePathForCompare(targetPath, { ignoreCase } = {}) { + const normalized = path.resolve(targetPath); + return ignoreCase ? normalized.toLowerCase() : normalized; }, - expandHomePath(value) { - return value; + expandHomePath(targetPath) { + return targetPath; }, - writeJsonAtomic(indexPath, payload) { - writtenIndex = { indexPath, payload }; + buildClaudeSessionIndexEntry(entry, sessionFilePath) { + return { ...entry, fullPath: sessionFilePath }; }, path, process: { platform: 'linux' } } ); - upsertClaudeSessionIndexEntry('/tmp/sessions-index.json', '/tmp/session.jsonl', { sessionId: 'new-entry' }); + upsertClaudeSessionIndexEntry('/tmp/sessions-index.json', '/tmp/session.jsonl', { sessionId: 'stale-id', note: 'new-entry' }); - assert(writtenIndex, 'index should be written'); - assert.strictEqual(writtenIndex.payload.entries.length, 2); - assert.strictEqual(writtenIndex.payload.entries[0].sessionId, 'new-entry'); - assert.strictEqual(writtenIndex.payload.entries[1].sessionId, 'case-sensitive-entry'); + assert.deepStrictEqual(writtenIndex, { + indexPath: '/tmp/sessions-index.json', + payload: { + entries: [ + { sessionId: 'stale-id', fullPath: '/tmp/session.jsonl', note: 'new-entry' }, + { sessionId: 'stale-id', fullPath: '/tmp/Session.jsonl', note: 'keep-case-sensitive' }, + { sessionId: 'stale-id', fullPath: '/tmp/other.jsonl', note: 'keep-other' } + ], + originalPath: '/tmp' + } + }); }); test('upsertClaudeSessionIndexEntry prefers normalized fullPath over stale sessionId when file path is known', () => { - let writtenIndex = null; const upsertClaudeSessionIndexEntrySource = extractFunctionBySignature( cliSource, 'function upsertClaudeSessionIndexEntry(indexPath, sessionFilePath, entry) {', 'upsertClaudeSessionIndexEntry' ); + let writtenIndex = null; const upsertClaudeSessionIndexEntry = instantiateFunction( upsertClaudeSessionIndexEntrySource, 'upsertClaudeSessionIndexEntry', @@ -466,60 +459,41 @@ test('upsertClaudeSessionIndexEntry prefers normalized fullPath over stale sessi readJsonFile() { return { entries: [ - { - sessionId: 'stale-id', - fullPath: '/tmp/other.jsonl', - note: 'keep-by-path' - }, - { - sessionId: 'fresh-id', - fullPath: '/tmp/session.jsonl', - note: 'replace-by-path' - } - ] + { sessionId: 'stale-id', fullPath: '/tmp/session.jsonl', note: 'replace-target' }, + { sessionId: 'stale-id', fullPath: '/tmp/other.jsonl', note: 'keep-by-path' } + ], + originalPath: '/tmp' }; }, - normalizePathForCompare(targetPath, options = {}) { - const resolved = path.resolve(targetPath); - return options.ignoreCase ? resolved.toLowerCase() : resolved; + writeJsonAtomic(indexPath, payload) { + writtenIndex = { indexPath, payload }; }, normalizeSessionTrashEntry(entry) { return entry; }, - buildClaudeSessionIndexEntry(entry, sessionFilePath) { - return { - sessionId: entry.sessionId, - fullPath: sessionFilePath, - note: 'new-entry' - }; + normalizePathForCompare(targetPath, { ignoreCase } = {}) { + const normalized = path.resolve(targetPath); + return ignoreCase ? normalized.toLowerCase() : normalized; }, - expandHomePath(value) { - return value; + expandHomePath(targetPath) { + return targetPath; }, - writeJsonAtomic(indexPath, payload) { - writtenIndex = { indexPath, payload }; + buildClaudeSessionIndexEntry(entry, sessionFilePath) { + return { ...entry, fullPath: sessionFilePath }; }, path, process: { platform: 'linux' } } ); - upsertClaudeSessionIndexEntry('/tmp/sessions-index.json', '/tmp/session.jsonl', { sessionId: 'stale-id' }); + upsertClaudeSessionIndexEntry('/tmp/sessions-index.json', '/tmp/session.jsonl', { sessionId: 'stale-id', note: 'new-entry' }); assert.deepStrictEqual(writtenIndex, { indexPath: '/tmp/sessions-index.json', payload: { entries: [ - { - sessionId: 'stale-id', - fullPath: '/tmp/session.jsonl', - note: 'new-entry' - }, - { - sessionId: 'stale-id', - fullPath: '/tmp/other.jsonl', - note: 'keep-by-path' - } + { sessionId: 'stale-id', fullPath: '/tmp/session.jsonl', note: 'new-entry' }, + { sessionId: 'stale-id', fullPath: '/tmp/other.jsonl', note: 'keep-by-path' } ], originalPath: '/tmp' } @@ -528,12 +502,10 @@ test('upsertClaudeSessionIndexEntry prefers normalized fullPath over stale sessi test('loadSessionTrashCount ignores stale responses after a newer trash request invalidates them', async () => { let resolveApi = null; - const loadSessionTrashCountSource = extractMethodAsFunction(appSource, 'loadSessionTrashCount'); - const loadSessionTrashCount = instantiateFunction(loadSessionTrashCountSource, 'loadSessionTrashCount', { - api: async () => await new Promise((resolve) => { - resolveApi = resolve; - }) - }); + const methods = createSessionTrashTestMethods(async () => await new Promise((resolve) => { + resolveApi = resolve; + })); + const loadSessionTrashCount = methods.loadSessionTrashCount; const context = { sessionTrashCountLoading: false, @@ -560,7 +532,7 @@ test('loadSessionTrashCount ignores stale responses after a newer trash request if (!Number.isFinite(numericTotal) || numericTotal < 0) { return fallbackCount; } - return Math.max(fallbackCount, Math.floor(numericTotal)); + return Math.floor(numericTotal); }, showMessage() { throw new Error('stale request should not surface a toast'); @@ -579,18 +551,9 @@ test('loadSessionTrashCount ignores stale responses after a newer trash request }); test('loadSessionTrashCount trusts a lower authoritative backend totalCount during count-only refresh', async () => { - const normalizeSessionTrashTotalCountSource = extractMethodAsFunction(appSource, 'normalizeSessionTrashTotalCount'); - const normalizeSessionTrashTotalCount = instantiateFunction( - normalizeSessionTrashTotalCountSource, - 'normalizeSessionTrashTotalCount' - ); - const loadSessionTrashCountSource = extractMethodAsFunction(appSource, 'loadSessionTrashCount'); - const loadSessionTrashCount = instantiateFunction(loadSessionTrashCountSource, 'loadSessionTrashCount', { - api: async () => ({ - totalCount: 0, - items: [] - }) - }); + const methods = createSessionTrashTestMethods(async () => ({ totalCount: 0, items: [] })); + const normalizeSessionTrashTotalCount = methods.normalizeSessionTrashTotalCount; + const loadSessionTrashCount = methods.loadSessionTrashCount; const context = { sessionTrashCountLoading: false, @@ -644,7 +607,7 @@ test('session trash desktop actions keep the action block right-aligned', () => test('loadSessionTrash and loadSessionTrashCount keep independent stale-response tokens', async () => { let resolveCount = null; let resolveList = null; - const api = async (action, params = {}) => await new Promise((resolve) => { + const methods = createSessionTrashTestMethods(async (action, params = {}) => await new Promise((resolve) => { if (action !== 'list-session-trash') { throw new Error(`unexpected action: ${action}`); } @@ -653,20 +616,12 @@ test('loadSessionTrash and loadSessionTrashCount keep independent stale-response return; } resolveList = resolve; + }), { + sessionTrashListLimit: 50, + sessionTrashPageSize: 50 }); - const loadSessionTrashCount = instantiateFunction( - extractMethodAsFunction(appSource, 'loadSessionTrashCount'), - 'loadSessionTrashCount', - { api } - ); - const loadSessionTrash = instantiateFunction( - extractMethodAsFunction(appSource, 'loadSessionTrash'), - 'loadSessionTrash', - { - SESSION_TRASH_LIST_LIMIT: 50, - api - } - ); + const loadSessionTrashCount = methods.loadSessionTrashCount; + const loadSessionTrash = methods.loadSessionTrash; const context = { sessionTrashItems: [], @@ -700,7 +655,7 @@ test('loadSessionTrash and loadSessionTrashCount keep independent stale-response if (!Number.isFinite(numericTotal) || numericTotal < 0) { return fallbackCount; } - return Math.max(fallbackCount, Math.floor(numericTotal)); + return Math.floor(numericTotal); }, resetSessionTrashVisibleCount() { this.sessionTrashVisibleCount = 50; @@ -731,8 +686,7 @@ test('loadSessionTrash and loadSessionTrashCount keep independent stale-response }); test('getSessionTrashViewState returns retry when badge count exists but list has never loaded', () => { - const getSessionTrashViewStateSource = extractMethodAsFunction(appSource, 'getSessionTrashViewState'); - const getSessionTrashViewState = instantiateFunction(getSessionTrashViewStateSource, 'getSessionTrashViewState'); + const getSessionTrashViewState = createSessionTrashTestMethods(async () => ({})).getSessionTrashViewState; assert.strictEqual(getSessionTrashViewState.call({ sessionTrashLoading: false, @@ -761,23 +715,22 @@ test('getSessionTrashViewState returns retry when badge count exists but list ha test('loadSessionTrash marks latest failures as retryable and clears the failure state after a successful reload', async () => { let callCount = 0; - const loadSessionTrashSource = extractMethodAsFunction(appSource, 'loadSessionTrash'); - const loadSessionTrash = instantiateFunction(loadSessionTrashSource, 'loadSessionTrash', { - SESSION_TRASH_LIST_LIMIT: 50, - api: async () => { - callCount += 1; - if (callCount === 1) { - return { error: 'load failed' }; - } - return { - totalCount: 1, - items: [{ - trashId: 'trash-1', - sessionId: 'session-1' - }] - }; + const loadSessionTrash = createSessionTrashTestMethods(async () => { + callCount += 1; + if (callCount === 1) { + return { error: 'load failed' }; } - }); + return { + totalCount: 1, + items: [{ + trashId: 'trash-1', + sessionId: 'session-1' + }] + }; + }, { + sessionTrashListLimit: 50, + sessionTrashPageSize: 50 + }).loadSessionTrash; const messages = []; const context = { @@ -801,7 +754,7 @@ test('loadSessionTrash marks latest failures as retryable and clears the failure if (!Number.isFinite(numericTotal) || numericTotal < 0) { return fallbackCount; } - return Math.max(fallbackCount, Math.floor(numericTotal)); + return Math.floor(numericTotal); }, resetSessionTrashVisibleCount() {}, showMessage(message, tone) { @@ -1605,9 +1558,9 @@ test('deleteSession increments trash badge count when only total count has been api: async (action) => { requestedAction = action; return ({ - trashId: 'trash-1', - deletedAt: '2025-03-30T00:00:00.000Z', - messageCount: 2 + trashId: 'trash-1', + deletedAt: '2025-03-30T00:00:00.000Z', + messageCount: 2 }); } }); @@ -1637,7 +1590,7 @@ test('deleteSession increments trash badge count when only total count has been if (!Number.isFinite(numericTotal) || numericTotal < 0) { return fallbackCount; } - return Math.max(fallbackCount, Math.floor(numericTotal)); + return Math.floor(numericTotal); }, prependSessionTrashItem() { throw new Error('list hydration path should not run when only count is loaded'); @@ -1709,7 +1662,7 @@ test('deleteSession prefers authoritative trash totalCount from the backend resp if (!Number.isFinite(numericTotal) || numericTotal < 0) { return fallbackCount; } - return Math.max(fallbackCount, Math.floor(numericTotal)); + return Math.floor(numericTotal); }, prependSessionTrashItem() { throw new Error('loaded-list branch should not run in count-only test'); @@ -1740,11 +1693,10 @@ test('deleteSession prefers authoritative trash totalCount from the backend resp }); test('prependSessionTrashItem prefers authoritative trash totalCount when provided', () => { - const prependSessionTrashItemSource = extractMethodAsFunction(appSource, 'prependSessionTrashItem'); - const prependSessionTrashItem = instantiateFunction(prependSessionTrashItemSource, 'prependSessionTrashItem', { - SESSION_TRASH_LIST_LIMIT: 500, - SESSION_TRASH_PAGE_SIZE: 200 - }); + const prependSessionTrashItem = createSessionTrashTestMethods(async () => ({}), { + sessionTrashListLimit: 500, + sessionTrashPageSize: 200 + }).prependSessionTrashItem; const context = { sessionTrashItems: [{ @@ -1757,7 +1709,7 @@ test('prependSessionTrashItem prefers authoritative trash totalCount when provid if (!Number.isFinite(numericTotal) || numericTotal < 0) { return fallbackCount; } - return Math.max(fallbackCount, Math.floor(numericTotal)); + return Math.floor(numericTotal); }, getSessionTrashActionKey(item) { return item && item.trashId; @@ -1905,16 +1857,15 @@ test('restoreSessionPinnedMap normalizes cache without pruning stale entries bef }); test('loadSessionTrash replays the latest queued refresh after an in-flight request is invalidated', async () => { - const loadSessionTrashSource = extractMethodAsFunction(appSource, 'loadSessionTrash'); const pendingResponses = []; const apiCalls = []; - const loadSessionTrash = instantiateFunction(loadSessionTrashSource, 'loadSessionTrash', { - SESSION_TRASH_LIST_LIMIT: 500, - api: async (action, params) => await new Promise((resolve) => { - apiCalls.push({ action, params }); - pendingResponses.push(resolve); - }) - }); + const loadSessionTrash = createSessionTrashTestMethods(async (action, params) => await new Promise((resolve) => { + apiCalls.push({ action, params }); + pendingResponses.push(resolve); + }), { + sessionTrashListLimit: 500, + sessionTrashPageSize: 200 + }).loadSessionTrash; const context = { sessionTrashItems: [], @@ -1944,7 +1895,7 @@ test('loadSessionTrash replays the latest queued refresh after an in-flight requ if (!Number.isFinite(numericTotal) || numericTotal < 0) { return fallbackCount; } - return Math.max(fallbackCount, Math.floor(numericTotal)); + return Math.floor(numericTotal); }, resetSessionTrashVisibleCount() { this.sessionTrashVisibleCount = 200; @@ -1985,26 +1936,12 @@ test('loadSessionTrash replays the latest queued refresh after an in-flight requ test('session trash restore and purge share the same per-item busy guard', async () => { const apiCalls = []; - const restoreSessionTrash = instantiateFunction( - extractMethodAsFunction(appSource, 'restoreSessionTrash'), - 'restoreSessionTrash', - { - api: async (action) => { - apiCalls.push(action); - return { success: true }; - } - } - ); - const purgeSessionTrash = instantiateFunction( - extractMethodAsFunction(appSource, 'purgeSessionTrash'), - 'purgeSessionTrash', - { - api: async (action) => { - apiCalls.push(action); - return { success: true }; - } - } - ); + const methods = createSessionTrashTestMethods(async (action) => { + apiCalls.push(action); + return { success: true }; + }); + const restoreSessionTrash = methods.restoreSessionTrash; + const purgeSessionTrash = methods.purgeSessionTrash; let confirmCalls = 0; const context = { @@ -2038,3 +1975,12 @@ test('session trash restore and purge share the same per-item busy guard', async assert.deepStrictEqual(apiCalls, []); assert.strictEqual(confirmCalls, 0); }); + +test('session trash module exports sensible default limits and resolvers', () => { + assert.strictEqual(DEFAULT_SESSION_TRASH_LIST_LIMIT, 500); + assert.strictEqual(DEFAULT_SESSION_TRASH_PAGE_SIZE, 200); + assert.strictEqual(resolveSessionTrashListLimit({}, {}), 500); + assert.strictEqual(resolveSessionTrashPageSize({}, {}), 200); + assert.strictEqual(resolveSessionTrashListLimit({ SESSION_TRASH_LIST_LIMIT: 123 }, {}), 123); + assert.strictEqual(resolveSessionTrashPageSize({ SESSION_TRASH_PAGE_SIZE: 45 }, {}), 45); +}); diff --git a/web-ui/app.js b/web-ui/app.js index b75867b..e290b2a 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -32,6 +32,7 @@ import { } from './modules/config-mode.computed.mjs'; import { createSkillsComputed } from './modules/skills.computed.mjs'; import { createSkillsMethods } from './modules/skills.methods.mjs'; +import { createSessionTrashMethods } from './modules/session-trash.methods.mjs'; document.addEventListener('DOMContentLoaded', () => { if (typeof Vue === 'undefined') { @@ -1886,152 +1887,6 @@ import { createSkillsMethods } from './modules/skills.methods.mjs'; } }, - buildSessionTrashItemFromSession(session, result = {}) { - const deletedAt = typeof result.deletedAt === 'string' && result.deletedAt - ? result.deletedAt - : new Date().toISOString(); - const source = session && session.source === 'claude' ? 'claude' : 'codex'; - return { - trashId: typeof result.trashId === 'string' ? result.trashId : '', - source, - sourceLabel: session && typeof session.sourceLabel === 'string' && session.sourceLabel - ? session.sourceLabel - : (source === 'claude' ? 'Claude Code' : 'Codex'), - sessionId: session && typeof session.sessionId === 'string' ? session.sessionId : '', - title: session && typeof session.title === 'string' && session.title - ? session.title - : (session && typeof session.sessionId === 'string' ? session.sessionId : ''), - cwd: session && typeof session.cwd === 'string' ? session.cwd : '', - createdAt: session && typeof session.createdAt === 'string' ? session.createdAt : '', - updatedAt: session && typeof session.updatedAt === 'string' ? session.updatedAt : '', - deletedAt, - messageCount: Number.isFinite(Number(result && result.messageCount)) - ? Math.max(0, Math.floor(Number(result.messageCount))) - : (Number.isFinite(Number(session && session.messageCount)) - ? Math.max(0, Math.floor(Number(session.messageCount))) - : 0), - originalFilePath: session && typeof session.filePath === 'string' ? session.filePath : '', - provider: session && typeof session.provider === 'string' ? session.provider : source, - keywords: Array.isArray(session && session.keywords) ? session.keywords : [], - capabilities: session && typeof session.capabilities === 'object' && session.capabilities - ? session.capabilities - : {}, - claudeIndexPath: '', - claudeIndexEntry: null, - trashFilePath: '' - }; - }, - - prependSessionTrashItem(item, options = {}) { - if (!item || !item.trashId) { - return; - } - const existing = Array.isArray(this.sessionTrashItems) ? this.sessionTrashItems : []; - const filtered = existing.filter((entry) => this.getSessionTrashActionKey(entry) !== item.trashId); - const nextItems = [item, ...filtered].slice(0, SESSION_TRASH_LIST_LIMIT); - const previousTotalCount = Number(this.sessionTrashTotalCount); - const normalizedPreviousTotal = Number.isFinite(previousTotalCount) && previousTotalCount >= 0 - ? Math.max(existing.length, Math.floor(previousTotalCount)) - : existing.length; - this.sessionTrashItems = nextItems; - const previousVisibleCount = Number(this.sessionTrashVisibleCount); - const normalizedPreviousVisibleCount = Number.isFinite(previousVisibleCount) && previousVisibleCount > 0 - ? Math.floor(previousVisibleCount) - : SESSION_TRASH_PAGE_SIZE; - const wasFullyExpanded = normalizedPreviousVisibleCount >= existing.length - || normalizedPreviousVisibleCount >= normalizedPreviousTotal; - if (wasFullyExpanded) { - this.sessionTrashVisibleCount = Math.min( - normalizedPreviousVisibleCount + 1, - nextItems.length || (normalizedPreviousVisibleCount + 1) - ); - } - const fallbackTotalCount = filtered.length === existing.length - ? normalizedPreviousTotal + 1 - : normalizedPreviousTotal; - this.sessionTrashTotalCount = this.normalizeSessionTrashTotalCount( - options && options.totalCount !== undefined - ? options.totalCount - : fallbackTotalCount, - nextItems - ); - }, - - normalizeSessionTrashTotalCount(totalCount, fallbackItems = this.sessionTrashItems) { - const fallbackCount = Array.isArray(fallbackItems) ? fallbackItems.length : 0; - const numericTotal = Number(totalCount); - if (!Number.isFinite(numericTotal) || numericTotal < 0) { - return fallbackCount; - } - return Math.floor(numericTotal); - }, - - getSessionTrashViewState() { - if (this.sessionTrashLoading && !this.sessionTrashLoadedOnce) { - return 'loading'; - } - const totalCount = Number(this.sessionTrashCount); - const normalizedTotalCount = Number.isFinite(totalCount) && totalCount >= 0 - ? Math.floor(totalCount) - : 0; - const hasVisibleItems = Array.isArray(this.sessionTrashItems) && this.sessionTrashItems.length > 0; - if (this.sessionTrashLastLoadFailed && (!this.sessionTrashLoadedOnce || !hasVisibleItems)) { - return 'retry'; - } - if (!this.sessionTrashLoadedOnce) { - return normalizedTotalCount > 0 ? 'retry' : 'empty'; - } - if (normalizedTotalCount === 0) { - return 'empty'; - } - return hasVisibleItems ? 'list' : 'retry'; - }, - - issueSessionTrashCountRequestToken() { - const currentToken = Number(this.sessionTrashCountRequestToken); - const nextToken = Number.isFinite(currentToken) && currentToken >= 0 - ? Math.floor(currentToken) + 1 - : 1; - this.sessionTrashCountRequestToken = nextToken; - return nextToken; - }, - - issueSessionTrashListRequestToken() { - const currentToken = Number(this.sessionTrashListRequestToken); - const nextToken = Number.isFinite(currentToken) && currentToken >= 0 - ? Math.floor(currentToken) + 1 - : 1; - this.sessionTrashListRequestToken = nextToken; - return nextToken; - }, - - invalidateSessionTrashRequests() { - this.issueSessionTrashCountRequestToken(); - return this.issueSessionTrashListRequestToken(); - }, - - isLatestSessionTrashCountRequestToken(token) { - return Number(token) === Number(this.sessionTrashCountRequestToken); - }, - - isLatestSessionTrashListRequestToken(token) { - return Number(token) === Number(this.sessionTrashListRequestToken); - }, - - resetSessionTrashVisibleCount() { - const totalItems = Array.isArray(this.sessionTrashItems) ? this.sessionTrashItems.length : 0; - this.sessionTrashVisibleCount = Math.min(totalItems, SESSION_TRASH_PAGE_SIZE) || SESSION_TRASH_PAGE_SIZE; - }, - - loadMoreSessionTrashItems() { - const totalItems = Array.isArray(this.sessionTrashItems) ? this.sessionTrashItems.length : 0; - const visibleCount = Number(this.sessionTrashVisibleCount); - const safeVisibleCount = Number.isFinite(visibleCount) && visibleCount > 0 - ? Math.floor(visibleCount) - : SESSION_TRASH_PAGE_SIZE; - this.sessionTrashVisibleCount = Math.min(totalItems, safeVisibleCount + SESSION_TRASH_PAGE_SIZE); - }, - clearActiveSessionState() { this.activeSession = null; this.activeSessionMessages = []; @@ -2085,214 +1940,6 @@ import { createSkillsMethods } from './modules/skills.methods.mjs'; await this.selectSession(nextSession); }, - normalizeSettingsTab(tab) { - return tab === 'trash' ? 'trash' : 'backup'; - }, - - async onSettingsTabClick(tab) { - await this.switchSettingsTab(tab); - }, - - async switchSettingsTab(tab, options = {}) { - const nextTab = this.normalizeSettingsTab(tab); - this.settingsTab = nextTab; - if (nextTab !== 'trash') { - return; - } - const forceRefresh = options.forceRefresh === true; - if (forceRefresh || !this.sessionTrashLoadedOnce) { - await this.loadSessionTrash({ forceRefresh }); - } - }, - - async loadSessionTrashCount(options = {}) { - if (this.sessionTrashCountLoading) { - this.sessionTrashCountPendingOptions = { - ...(this.sessionTrashCountPendingOptions || {}), - ...(options || {}) - }; - return; - } - const requestToken = this.issueSessionTrashCountRequestToken(); - this.sessionTrashCountLoading = true; - try { - const res = await api('list-session-trash', { countOnly: true }); - if (!this.isLatestSessionTrashCountRequestToken(requestToken)) { - return; - } - if (res.error) { - if (options.silent !== true) { - this.showMessage(res.error, 'error'); - } - return; - } - this.sessionTrashTotalCount = this.normalizeSessionTrashTotalCount( - res.totalCount, - this.sessionTrashItems - ); - this.sessionTrashCountLoadedOnce = true; - } catch (e) { - if (this.isLatestSessionTrashCountRequestToken(requestToken) && options.silent !== true) { - this.showMessage('加载回收站数量失败', 'error'); - } - } finally { - this.sessionTrashCountLoading = false; - const pendingOptions = this.sessionTrashCountPendingOptions; - this.sessionTrashCountPendingOptions = null; - if (pendingOptions) { - await this.loadSessionTrashCount(pendingOptions); - } - } - }, - - getSessionTrashActionKey(item) { - return item && typeof item.trashId === 'string' ? item.trashId : ''; - }, - - isSessionTrashActionBusy(item) { - const key = typeof item === 'string' ? item : this.getSessionTrashActionKey(item); - return !!(key && (this.sessionTrashRestoring[key] || this.sessionTrashPurging[key])); - }, - - async loadSessionTrash(options = {}) { - if (this.sessionTrashLoading) { - this.sessionTrashPendingOptions = { - ...(this.sessionTrashPendingOptions || {}), - ...(options || {}) - }; - return; - } - const requestToken = this.issueSessionTrashListRequestToken(); - this.sessionTrashLoading = true; - this.sessionTrashLastLoadFailed = false; - let loadSucceeded = false; - try { - const res = await api('list-session-trash', { - limit: SESSION_TRASH_LIST_LIMIT, - forceRefresh: !!options.forceRefresh - }); - if (!this.isLatestSessionTrashListRequestToken(requestToken)) { - return; - } - if (res.error) { - this.sessionTrashLastLoadFailed = true; - this.showMessage(res.error, 'error'); - return; - } - const nextItems = Array.isArray(res.items) ? res.items : []; - this.sessionTrashItems = nextItems; - this.resetSessionTrashVisibleCount(); - this.sessionTrashTotalCount = this.normalizeSessionTrashTotalCount(res.totalCount, nextItems); - this.sessionTrashCountLoadedOnce = true; - this.sessionTrashLastLoadFailed = false; - loadSucceeded = true; - } catch (e) { - if (this.isLatestSessionTrashListRequestToken(requestToken)) { - this.sessionTrashLastLoadFailed = true; - this.showMessage('加载回收站失败', 'error'); - } - } finally { - this.sessionTrashLoading = false; - if (loadSucceeded) { - this.sessionTrashLoadedOnce = true; - } - const pendingOptions = this.sessionTrashPendingOptions; - this.sessionTrashPendingOptions = null; - if (pendingOptions) { - await this.loadSessionTrash(pendingOptions); - } - } - }, - - async restoreSessionTrash(item) { - const key = this.getSessionTrashActionKey(item); - if (!key || this.isSessionTrashActionBusy(key) || this.sessionTrashClearing) { - return; - } - this.sessionTrashRestoring[key] = true; - try { - const res = await api('restore-session-trash', { trashId: key }); - if (res.error) { - this.showMessage(res.error, 'error'); - return; - } - this.showMessage('会话已恢复', 'success'); - this.invalidateSessionTrashRequests(); - await this.loadSessionTrash({ forceRefresh: true }); - if (this.sessionsLoadedOnce) { - await this.loadSessions(); - } - } catch (e) { - this.showMessage('恢复失败', 'error'); - } finally { - this.sessionTrashRestoring[key] = false; - } - }, - - async purgeSessionTrash(item) { - const key = this.getSessionTrashActionKey(item); - if (!key || this.isSessionTrashActionBusy(key) || this.sessionTrashClearing) { - return; - } - const confirmed = await this.requestConfirmDialog({ - title: '彻底删除回收站记录', - message: '该会话将从回收站永久删除,且无法恢复。', - confirmText: '彻底删除', - cancelText: '取消', - danger: true - }); - if (!confirmed) { - return; - } - this.sessionTrashPurging[key] = true; - try { - const res = await api('purge-session-trash', { trashId: key }); - if (res.error) { - this.showMessage(res.error, 'error'); - return; - } - this.showMessage('已彻底删除', 'success'); - this.invalidateSessionTrashRequests(); - await this.loadSessionTrash({ forceRefresh: true }); - } catch (e) { - this.showMessage('彻底删除失败', 'error'); - } finally { - this.sessionTrashPurging[key] = false; - } - }, - - async clearSessionTrash() { - const normalizedCount = Number(this.sessionTrashCount); - if (this.sessionTrashClearing || !Number.isFinite(normalizedCount) || normalizedCount <= 0) { - return; - } - const confirmed = await this.requestConfirmDialog({ - title: '清空回收站', - message: '该操作会永久删除回收站中的全部会话,且无法恢复。', - confirmText: '全部清空', - cancelText: '取消', - danger: true - }); - if (!confirmed) { - return; - } - this.sessionTrashClearing = true; - try { - const res = await api('purge-session-trash', { all: true }); - if (res.error) { - this.showMessage(res.error, 'error'); - return; - } - this.showMessage('回收站已清空', 'success'); - this.invalidateSessionTrashRequests(); - await this.loadSessionTrash({ forceRefresh: true }); - } catch (e) { - this.showMessage('清空回收站失败', 'error'); - } finally { - this.sessionTrashClearing = false; - } - }, - normalizeSessionPathValue(value) { return normalizeSessionPathFilter(value); }, @@ -3412,6 +3059,13 @@ import { createSkillsMethods } from './modules/skills.methods.mjs'; }, ...createSkillsMethods({ api }), + ...createSessionTrashMethods({ + api, + constants: { + sessionTrashListLimit: SESSION_TRASH_LIST_LIMIT, + sessionTrashPageSize: SESSION_TRASH_PAGE_SIZE + } + }), async openOpenclawAgentsEditor() { this.setAgentsModalContext('openclaw'); diff --git a/web-ui/modules/session-trash.methods.mjs b/web-ui/modules/session-trash.methods.mjs new file mode 100644 index 0000000..94c54e3 --- /dev/null +++ b/web-ui/modules/session-trash.methods.mjs @@ -0,0 +1,380 @@ +export const DEFAULT_SESSION_TRASH_LIST_LIMIT = 500; +export const DEFAULT_SESSION_TRASH_PAGE_SIZE = 200; + +export function resolveSessionTrashListLimit(context, constants = {}) { + return Number( + constants.sessionTrashListLimit + ?? context?.sessionTrashListLimit + ?? context?.SESSION_TRASH_LIST_LIMIT + ) || DEFAULT_SESSION_TRASH_LIST_LIMIT; +} + +export function resolveSessionTrashPageSize(context, constants = {}) { + return Number( + constants.sessionTrashPageSize + ?? context?.sessionTrashPageSize + ?? context?.SESSION_TRASH_PAGE_SIZE + ) || DEFAULT_SESSION_TRASH_PAGE_SIZE; +} + +export function createSessionTrashMethods({ api, constants = {} } = {}) { + return { + normalizeSettingsTab(tab) { + return tab === 'trash' ? 'trash' : 'backup'; + }, + + async onSettingsTabClick(tab) { + await this.switchSettingsTab(tab); + }, + + async switchSettingsTab(tab, options = {}) { + const nextTab = this.normalizeSettingsTab(tab); + this.settingsTab = nextTab; + if (nextTab !== 'trash') { + return; + } + const forceRefresh = options.forceRefresh === true; + if (forceRefresh || !this.sessionTrashLoadedOnce) { + await this.loadSessionTrash({ forceRefresh }); + } + }, + + async loadSessionTrashCount(options = {}) { + if (this.sessionTrashCountLoading) { + this.sessionTrashCountPendingOptions = { + ...(this.sessionTrashCountPendingOptions || {}), + ...(options || {}) + }; + return; + } + const requestToken = this.issueSessionTrashCountRequestToken(); + this.sessionTrashCountLoading = true; + try { + const res = await api('list-session-trash', { countOnly: true }); + if (!this.isLatestSessionTrashCountRequestToken(requestToken)) { + return; + } + if (res.error) { + if (options.silent !== true) { + this.showMessage(res.error, 'error'); + } + return; + } + this.sessionTrashTotalCount = this.normalizeSessionTrashTotalCount( + res.totalCount, + this.sessionTrashItems + ); + this.sessionTrashCountLoadedOnce = true; + } catch (e) { + if (this.isLatestSessionTrashCountRequestToken(requestToken) && options.silent !== true) { + this.showMessage('加载回收站数量失败', 'error'); + } + } finally { + this.sessionTrashCountLoading = false; + const pendingOptions = this.sessionTrashCountPendingOptions; + this.sessionTrashCountPendingOptions = null; + if (pendingOptions) { + await this.loadSessionTrashCount(pendingOptions); + } + } + }, + + getSessionTrashActionKey(item) { + return item && typeof item.trashId === 'string' ? item.trashId : ''; + }, + + isSessionTrashActionBusy(item) { + const key = typeof item === 'string' ? item : this.getSessionTrashActionKey(item); + return !!(key && (this.sessionTrashRestoring[key] || this.sessionTrashPurging[key])); + }, + + async loadSessionTrash(options = {}) { + if (this.sessionTrashLoading) { + this.sessionTrashPendingOptions = { + ...(this.sessionTrashPendingOptions || {}), + ...(options || {}) + }; + return; + } + const requestToken = this.issueSessionTrashListRequestToken(); + this.sessionTrashLoading = true; + this.sessionTrashLastLoadFailed = false; + let loadSucceeded = false; + try { + const res = await api('list-session-trash', { + limit: resolveSessionTrashListLimit(this, constants), + forceRefresh: !!options.forceRefresh + }); + if (!this.isLatestSessionTrashListRequestToken(requestToken)) { + return; + } + if (res.error) { + this.sessionTrashLastLoadFailed = true; + this.showMessage(res.error, 'error'); + return; + } + const nextItems = Array.isArray(res.items) ? res.items : []; + this.sessionTrashItems = nextItems; + this.resetSessionTrashVisibleCount(); + this.sessionTrashTotalCount = this.normalizeSessionTrashTotalCount(res.totalCount, nextItems); + this.sessionTrashCountLoadedOnce = true; + this.sessionTrashLastLoadFailed = false; + loadSucceeded = true; + } catch (e) { + if (this.isLatestSessionTrashListRequestToken(requestToken)) { + this.sessionTrashLastLoadFailed = true; + this.showMessage(e && e.message ? e.message : '加载回收站失败', 'error'); + } + } finally { + this.sessionTrashLoading = false; + if (loadSucceeded) { + this.sessionTrashLoadedOnce = true; + } + const pendingOptions = this.sessionTrashPendingOptions; + this.sessionTrashPendingOptions = null; + if (pendingOptions) { + await this.loadSessionTrash(pendingOptions); + } + } + }, + + async restoreSessionTrash(item) { + const key = this.getSessionTrashActionKey(item); + if (!key || this.isSessionTrashActionBusy(key) || this.sessionTrashClearing) { + return; + } + this.sessionTrashRestoring[key] = true; + try { + const res = await api('restore-session-trash', { trashId: key }); + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + this.showMessage('会话已恢复', 'success'); + this.invalidateSessionTrashRequests(); + await this.loadSessionTrash({ forceRefresh: true }); + if (this.sessionsLoadedOnce) { + await this.loadSessions(); + } + } catch (e) { + this.showMessage('恢复失败', 'error'); + } finally { + this.sessionTrashRestoring[key] = false; + } + }, + + async purgeSessionTrash(item) { + const key = this.getSessionTrashActionKey(item); + if (!key || this.isSessionTrashActionBusy(key) || this.sessionTrashClearing) { + return; + } + const confirmed = await this.requestConfirmDialog({ + title: '彻底删除回收站记录', + message: '该会话将从回收站永久删除,且无法恢复。', + confirmText: '彻底删除', + cancelText: '取消', + danger: true + }); + if (!confirmed) { + return; + } + this.sessionTrashPurging[key] = true; + try { + const res = await api('purge-session-trash', { trashId: key }); + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + this.showMessage('已彻底删除', 'success'); + this.invalidateSessionTrashRequests(); + await this.loadSessionTrash({ forceRefresh: true }); + } catch (e) { + this.showMessage('彻底删除失败', 'error'); + } finally { + this.sessionTrashPurging[key] = false; + } + }, + + async clearSessionTrash() { + const normalizedCount = Number(this.sessionTrashCount); + if (this.sessionTrashClearing || !Number.isFinite(normalizedCount) || normalizedCount <= 0) { + return; + } + const confirmed = await this.requestConfirmDialog({ + title: '清空回收站', + message: '该操作会永久删除回收站中的全部会话,且无法恢复。', + confirmText: '全部清空', + cancelText: '取消', + danger: true + }); + if (!confirmed) { + return; + } + this.sessionTrashClearing = true; + try { + const res = await api('purge-session-trash', { all: true }); + if (res.error) { + this.showMessage(res.error, 'error'); + return; + } + this.showMessage('回收站已清空', 'success'); + this.invalidateSessionTrashRequests(); + await this.loadSessionTrash({ forceRefresh: true }); + } catch (e) { + this.showMessage('清空回收站失败', 'error'); + } finally { + this.sessionTrashClearing = false; + } + }, + + buildSessionTrashItemFromSession(session, result = {}) { + const deletedAt = typeof result.deletedAt === 'string' && result.deletedAt + ? result.deletedAt + : new Date().toISOString(); + const source = session && session.source === 'claude' ? 'claude' : 'codex'; + return { + trashId: typeof result.trashId === 'string' ? result.trashId : '', + source, + sourceLabel: session && typeof session.sourceLabel === 'string' && session.sourceLabel + ? session.sourceLabel + : (source === 'claude' ? 'Claude Code' : 'Codex'), + sessionId: session && typeof session.sessionId === 'string' ? session.sessionId : '', + title: session && typeof session.title === 'string' && session.title + ? session.title + : (session && typeof session.sessionId === 'string' ? session.sessionId : ''), + cwd: session && typeof session.cwd === 'string' ? session.cwd : '', + createdAt: session && typeof session.createdAt === 'string' ? session.createdAt : '', + updatedAt: session && typeof session.updatedAt === 'string' ? session.updatedAt : '', + deletedAt, + messageCount: Number.isFinite(Number(result && result.messageCount)) + ? Math.max(0, Math.floor(Number(result.messageCount))) + : (Number.isFinite(Number(session && session.messageCount)) + ? Math.max(0, Math.floor(Number(session.messageCount))) + : 0), + originalFilePath: session && typeof session.filePath === 'string' ? session.filePath : '', + provider: session && typeof session.provider === 'string' ? session.provider : source, + keywords: Array.isArray(session && session.keywords) ? session.keywords : [], + capabilities: session && typeof session.capabilities === 'object' && session.capabilities + ? session.capabilities + : {}, + claudeIndexPath: '', + claudeIndexEntry: null, + trashFilePath: '' + }; + }, + + prependSessionTrashItem(item, options = {}) { + if (!item || !item.trashId) { + return; + } + const listLimit = resolveSessionTrashListLimit(this, constants); + const pageSize = resolveSessionTrashPageSize(this, constants); + const existing = Array.isArray(this.sessionTrashItems) ? this.sessionTrashItems : []; + const filtered = existing.filter((entry) => this.getSessionTrashActionKey(entry) !== item.trashId); + const nextItems = [item, ...filtered].slice(0, listLimit); + const previousTotalCount = Number(this.sessionTrashTotalCount); + const normalizedPreviousTotal = Number.isFinite(previousTotalCount) && previousTotalCount >= 0 + ? Math.max(existing.length, Math.floor(previousTotalCount)) + : existing.length; + this.sessionTrashItems = nextItems; + const previousVisibleCount = Number(this.sessionTrashVisibleCount); + const normalizedPreviousVisibleCount = Number.isFinite(previousVisibleCount) && previousVisibleCount > 0 + ? Math.floor(previousVisibleCount) + : pageSize; + const wasFullyExpanded = normalizedPreviousVisibleCount >= existing.length + || normalizedPreviousVisibleCount >= normalizedPreviousTotal; + if (wasFullyExpanded) { + this.sessionTrashVisibleCount = Math.min( + normalizedPreviousVisibleCount + 1, + nextItems.length || (normalizedPreviousVisibleCount + 1) + ); + } + const fallbackTotalCount = filtered.length === existing.length + ? normalizedPreviousTotal + 1 + : normalizedPreviousTotal; + this.sessionTrashTotalCount = this.normalizeSessionTrashTotalCount( + options && options.totalCount !== undefined + ? options.totalCount + : fallbackTotalCount, + nextItems + ); + }, + + normalizeSessionTrashTotalCount(totalCount, fallbackItems = this.sessionTrashItems) { + const fallbackCount = Array.isArray(fallbackItems) ? fallbackItems.length : 0; + const numericTotal = Number(totalCount); + if (!Number.isFinite(numericTotal) || numericTotal < 0) { + return fallbackCount; + } + return Math.floor(numericTotal); + }, + + getSessionTrashViewState() { + if (this.sessionTrashLoading && !this.sessionTrashLoadedOnce) { + return 'loading'; + } + const totalCount = Number(this.sessionTrashCount); + const normalizedTotalCount = Number.isFinite(totalCount) && totalCount >= 0 + ? Math.floor(totalCount) + : 0; + const hasVisibleItems = Array.isArray(this.sessionTrashItems) && this.sessionTrashItems.length > 0; + if (this.sessionTrashLastLoadFailed && (!this.sessionTrashLoadedOnce || !hasVisibleItems)) { + return 'retry'; + } + if (!this.sessionTrashLoadedOnce) { + return normalizedTotalCount > 0 ? 'retry' : 'empty'; + } + if (normalizedTotalCount === 0) { + return 'empty'; + } + return hasVisibleItems ? 'list' : 'retry'; + }, + + issueSessionTrashCountRequestToken() { + const currentToken = Number(this.sessionTrashCountRequestToken); + const nextToken = Number.isFinite(currentToken) && currentToken >= 0 + ? Math.floor(currentToken) + 1 + : 1; + this.sessionTrashCountRequestToken = nextToken; + return nextToken; + }, + + issueSessionTrashListRequestToken() { + const currentToken = Number(this.sessionTrashListRequestToken); + const nextToken = Number.isFinite(currentToken) && currentToken >= 0 + ? Math.floor(currentToken) + 1 + : 1; + this.sessionTrashListRequestToken = nextToken; + return nextToken; + }, + + invalidateSessionTrashRequests() { + this.issueSessionTrashCountRequestToken(); + return this.issueSessionTrashListRequestToken(); + }, + + isLatestSessionTrashCountRequestToken(token) { + return Number(token) === Number(this.sessionTrashCountRequestToken); + }, + + isLatestSessionTrashListRequestToken(token) { + return Number(token) === Number(this.sessionTrashListRequestToken); + }, + + resetSessionTrashVisibleCount() { + const pageSize = resolveSessionTrashPageSize(this, constants); + const totalItems = Array.isArray(this.sessionTrashItems) ? this.sessionTrashItems.length : 0; + this.sessionTrashVisibleCount = Math.min(totalItems, pageSize) || pageSize; + }, + + loadMoreSessionTrashItems() { + const pageSize = resolveSessionTrashPageSize(this, constants); + const totalItems = Array.isArray(this.sessionTrashItems) ? this.sessionTrashItems.length : 0; + const visibleCount = Number(this.sessionTrashVisibleCount); + const safeVisibleCount = Number.isFinite(visibleCount) && visibleCount > 0 + ? Math.floor(visibleCount) + : pageSize; + this.sessionTrashVisibleCount = Math.min(totalItems, safeVisibleCount + pageSize); + } + }; +}