diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index 55eea5a..14f29c4 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -143,6 +143,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/); @@ -225,6 +226,8 @@ test('web ui script defines provider mode metadata for codex only', () => { assert.doesNotMatch(appScript, /skillsMarketRemoteLatestOnly:\s*true/); assert.doesNotMatch(appScript, /skillsMarketEcosystems:\s*\[\]/); assert.match(appScript, /sessionTrashItems:\s*\[\]/); + assert.match(appScript, /createSessionTrashMethods/); + assert.match(appScript, /\.\.\.createSessionTrashMethods\(/); assert.match(appScript, /sessionTrashVisibleCount:\s*SESSION_TRASH_PAGE_SIZE/); assert.match(appScript, /sessionTrashTotalCount:\s*0/); assert.match(appScript, /sessionTrashLoadedOnce:\s*false/); @@ -233,21 +236,20 @@ 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, /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..0c08ff9 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 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); @@ -14,6 +14,8 @@ const appSource = fs.readFileSync(appPath, 'utf-8'); 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 } = sessionTrashModule; function escapeRegExp(value) { return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -195,6 +197,10 @@ function instantiateFunction(funcSource, funcName, bindings = {}) { return Function(...bindingNames, `${funcSource}\nreturn ${funcName};`)(...bindingValues); } +function createSessionTrashTestMethods(api, constants = {}) { + return createSessionTrashMethods({ api, constants }); +} + test('buildClaudeSessionIndexEntry prefers normalized metadata when stored index entry is missing or stale', () => { const buildClaudeStoredIndexMessageCountSource = extractFunctionBySignature( cliSource, @@ -528,12 +534,9 @@ 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 loadSessionTrashCount = createSessionTrashTestMethods(async () => await new Promise((resolve) => { + resolveApi = resolve; + })).loadSessionTrashCount; const context = { sessionTrashCountLoading: false, @@ -579,18 +582,11 @@ 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 normalizeSessionTrashTotalCount = createSessionTrashTestMethods(async () => ({})).normalizeSessionTrashTotalCount; + const loadSessionTrashCount = createSessionTrashTestMethods(async () => ({ + totalCount: 0, + items: [] + })).loadSessionTrashCount; const context = { sessionTrashCountLoading: false, @@ -654,19 +650,8 @@ test('loadSessionTrash and loadSessionTrashCount keep independent stale-response } resolveList = resolve; }); - const loadSessionTrashCount = instantiateFunction( - extractMethodAsFunction(appSource, 'loadSessionTrashCount'), - 'loadSessionTrashCount', - { api } - ); - const loadSessionTrash = instantiateFunction( - extractMethodAsFunction(appSource, 'loadSessionTrash'), - 'loadSessionTrash', - { - SESSION_TRASH_LIST_LIMIT: 50, - api - } - ); + const loadSessionTrashCount = createSessionTrashTestMethods(api, { sessionTrashListLimit: 50 }).loadSessionTrashCount; + const loadSessionTrash = createSessionTrashTestMethods(api, { sessionTrashListLimit: 50 }).loadSessionTrash; const context = { sessionTrashItems: [], @@ -731,8 +716,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 +745,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 = { @@ -1740,11 +1723,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: [{ @@ -1905,16 +1887,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: [], @@ -1985,26 +1966,14 @@ 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 restoreSessionTrash = createSessionTrashTestMethods(async (action) => { + apiCalls.push(action); + return { success: true }; + }).restoreSessionTrash; + const purgeSessionTrash = createSessionTrashTestMethods(async (action) => { + apiCalls.push(action); + return { success: true }; + }).purgeSessionTrash; let confirmCalls = 0; const context = { diff --git a/web-ui/app.js b/web-ui/app.js index 257fde3..e3928bb 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') { @@ -1884,152 +1885,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 = []; @@ -2083,214 +1938,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); }, @@ -3554,6 +3201,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..aac55b7 --- /dev/null +++ b/web-ui/modules/session-trash.methods.mjs @@ -0,0 +1,360 @@ +export function createSessionTrashMethods({ api, constants = {} } = {}) { + const sessionTrashListLimit = Number(constants.sessionTrashListLimit) || 500; + const sessionTrashPageSize = Number(constants.sessionTrashPageSize) || 200; + + 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: sessionTrashListLimit, + 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; + } + }, + + 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, sessionTrashListLimit); + 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) + : sessionTrashPageSize; + 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, sessionTrashPageSize) || sessionTrashPageSize; + }, + + 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) + : sessionTrashPageSize; + this.sessionTrashVisibleCount = Math.min(totalItems, safeVisibleCount + sessionTrashPageSize); + } + }; +}