diff --git a/CHANGELOG.md b/CHANGELOG.md index 848f26d..1e64057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to Codex Extras are documented in this file. +## Unreleased + +- Hide archived chats even when they remain in the session index. +- Refresh after archiving or restoring chats without requiring an index change; + use scoped VS Code file system watchers, coalesce bursts of events, and + support nested archives and compressed `.jsonl.zst` files. + ## 1.1.0 - Add a Codex Chats Activity Bar panel with local chat history, automatic diff --git a/README.md b/README.md index b5c5777..5f641cc 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ conversations ordered by their most recent activity. - Select a chat to open it as a pinned Codex editor tab. - Start a new chat or refresh the list from the panel toolbar. - New and renamed chats appear automatically while VS Code is running. +- Archived chats are hidden, including compressed archives. File system events + refresh the list after archiving or restoring a chat, without polling the archive. The panel reads Codex's local session index from `$CODEX_HOME/session_index.jsonl` when `CODEX_HOME` is set, or from `~/.codex/session_index.jsonl` otherwise. diff --git a/extension.js b/extension.js index aae4c3d..82e259d 100644 --- a/extension.js +++ b/extension.js @@ -4,6 +4,7 @@ const fs = require("node:fs"); const { readFile } = require("node:fs/promises"); const path = require("node:path"); const vscode = require("vscode"); +const { loadArchivedSessionIds, watchArchivedSessions } = require("./session-archive"); const { getSessionIndexPath, getSessionsDirectory, @@ -80,6 +81,7 @@ class CodexChatHistoryProvider { constructor(sessionIndexPath, sessionsDirectory, getWorkspaceFolders) { this.sessionIndexPath = sessionIndexPath; this.sessionsDirectory = sessionsDirectory; + this.archiveDirectory = path.join(path.dirname(sessionIndexPath), "archived_sessions"); this.getWorkspaceFolders = getWorkspaceFolders; this.workspaceBySessionId = new Map(); this.changeEmitter = new vscode.EventEmitter(); @@ -107,7 +109,10 @@ class CodexChatHistoryProvider { try { const contents = await readFile(this.sessionIndexPath, "utf8"); - const sessions = parseSessionIndex(contents); + const archivedIds = await loadArchivedSessionIds(this.archiveDirectory); + const sessions = parseSessionIndex(contents).filter( + (session) => !archivedIds.has(session.id.toLowerCase()), + ); const missingWorkspaceIds = sessions .filter((session) => !this.workspaceBySessionId.has(session.id)) .map((session) => session.id); @@ -391,6 +396,7 @@ function activate(context) { visibilitySubscription, workspaceSubscription, watchSessionIndex(sessionIndexPath, () => historyProvider.refresh()), + watchArchivedSessions(historyProvider.archiveDirectory, () => historyProvider.refresh(), vscode), ); } diff --git a/session-archive.js b/session-archive.js new file mode 100644 index 0000000..43e1dfc --- /dev/null +++ b/session-archive.js @@ -0,0 +1,66 @@ +"use strict"; + +const { readdir } = require("node:fs/promises"); +const path = require("node:path"); +const SESSION_ID_IN_FILENAME = + /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl(?:\.zst)?$/i; + +// Read names only; archived transcripts may be compressed. +async function loadArchivedSessionIds(directory) { + const ids = new Set(); + async function visit(current) { + let entries; + try { + entries = await readdir(current, { withFileTypes: true }); + } catch (error) { + if (error.code === "ENOENT") return; + throw error; + } + for (const entry of entries) { + if (entry.isDirectory()) { + await visit(path.join(current, entry.name)); + } else if (entry.isFile()) { + const id = entry.name.match(SESSION_ID_IN_FILENAME)?.[1]; + if (id) ids.add(id.toLowerCase()); + } + } + } + await visit(directory); + return ids; +} + +// Watch the archive recursively, plus its parent non-recursively so creating +// or replacing the entire archive also refreshes the list. VS Code handles +// attaching the recursive watcher when a missing directory is created. +function watchArchivedSessions(directory, onChange, vscode) { + let timer; + const scheduleRefresh = () => { + clearTimeout(timer); + timer = setTimeout(onChange, 100); + }; + const archiveWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(directory, "**"), false, true, false, + ); + const parentWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(path.dirname(directory), "*"), false, true, false, + ); + const onParentChange = (uri) => { + if (path.relative(directory, uri.fsPath) === "") scheduleRefresh(); + }; + const subscriptions = [ + archiveWatcher.onDidCreate(scheduleRefresh), + archiveWatcher.onDidDelete(scheduleRefresh), + parentWatcher.onDidCreate(onParentChange), + parentWatcher.onDidDelete(onParentChange), + ]; + return { + dispose() { + clearTimeout(timer); + for (const subscription of subscriptions) subscription.dispose(); + archiveWatcher.dispose(); + parentWatcher.dispose(); + }, + }; +} + +module.exports = { loadArchivedSessionIds, watchArchivedSessions }; diff --git a/test/extension.test.cjs b/test/extension.test.cjs index 24202a6..1d9841d 100644 --- a/test/extension.test.cjs +++ b/test/extension.test.cjs @@ -6,10 +6,53 @@ const Module = require("node:module"); const os = require("node:os"); const path = require("node:path"); const test = require("node:test"); +const { setTimeout: delay } = require("node:timers/promises"); const extensionPath = path.resolve(__dirname, "..", "extension.js"); const originalLoad = Module._load; +test("hides an already archived session that remains in the index", async (t) => { + const codexHome = await mkdtemp(path.join(os.tmpdir(), "codex-extras-archived-")); + t.after(() => rm(codexHome, { recursive: true, force: true })); + const id = "019f9c23-f7ec-72c3-8eb6-0018b17bea4f"; + await mkdir(path.join(codexHome, "archived_sessions")); + await writeFile(path.join(codexHome, "archived_sessions", `rollout-${id}.jsonl`), ""); + await writeFile(path.join(codexHome, "session_index.jsonl"), JSON.stringify({ id })); + const harness = createHarness(t, [], { codexHome, workspaceFolders: [] }); + assert.deepEqual(await harness.treeViews[0].options.treeDataProvider.getChildren(), []); +}); + +test("hides archived chats and refreshes on archive and restore without index changes", async (t) => { + const codexHome = await mkdtemp(path.join(os.tmpdir(), "codex-extras-archive-")); + t.after(() => rm(codexHome, { recursive: true, force: true })); + const id = "019f9c23-f7ec-72c3-8eb6-0018b17bea4f"; + const index = JSON.stringify({ id, thread_name: "Archived regression" }); + await writeFile(path.join(codexHome, "session_index.jsonl"), index); + const harness = createHarness(t, [], { codexHome, workspaceFolders: [] }); + const provider = harness.treeViews[0].options.treeDataProvider; + let changes = 0; + provider.onDidChangeTreeData(() => changes++); + const waitForChange = async (previous) => { + const deadline = Date.now() + 5000; + while (changes === previous && Date.now() < deadline) await delay(25); + assert.ok(changes > previous, "archive changes must refresh the panel"); + }; + assert.equal((await provider.getChildren())[0].sessions.length, 1); + const archive = path.join(codexHome, "archived_sessions", "2026", "10"); + await mkdir(archive, { recursive: true }); + let previous = changes; + const archivedFile = path.join(archive, `rollout-${id}.jsonl.zst`); + await writeFile(archivedFile, "compressed contents need not be read"); + await waitForChange(previous); + assert.deepEqual(await provider.getChildren(), []); + previous = changes; + await rm(archivedFile); + await waitForChange(previous); + assert.equal((await provider.getChildren())[0].sessions[0].id, id); + const { readFile } = require("node:fs/promises"); + assert.equal(await readFile(path.join(codexHome, "session_index.jsonl"), "utf8"), index); +}); + function loadExtension(vscode) { delete require.cache[extensionPath]; Module._load = function load(request, parent, isMain) { @@ -63,6 +106,12 @@ function createHarness(t, tabs = [], options = {}) { } const vscode = { + RelativePattern: class RelativePattern { + constructor(base, pattern) { + this.base = base; + this.pattern = pattern; + } + }, commands: { registerCommand(command, handler) { commandHandlers.set(command, handler); @@ -93,6 +142,36 @@ function createHarness(t, tabs = [], options = {}) { Expanded: 2, }, workspace: { + createFileSystemWatcher(pattern) { + const created = new EventEmitter(); + const deleted = new EventEmitter(); + // Exercise real filesystem changes in the temporary Codex home. The + // production extension delegates watcher lifecycle to VS Code. + let nativeWatcher; + if (options.codexHome) { + nativeWatcher = require("node:fs").watch( + options.codexHome, { recursive: true }, (event, filename) => { + if (event !== "rename" || !filename) return; + const fsPath = path.join(options.codexHome, filename); + const relative = path.relative(pattern.base, fsPath); + const matches = pattern.pattern === "*" + ? relative !== "" && !relative.includes(path.sep) && !relative.startsWith("..") + : relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative); + if (matches) created.fire({ fsPath }); + }, + ); + nativeWatcher.on("error", () => {}); + } + return { + onDidCreate: created.event, + onDidDelete: deleted.event, + dispose() { + nativeWatcher?.close(); + created.dispose(); + deleted.dispose(); + }, + }; + }, workspaceFolders: options.workspaceFolders ?? [ { name: "codex-extras", diff --git a/test/session-archive.test.cjs b/test/session-archive.test.cjs new file mode 100644 index 0000000..3e27138 --- /dev/null +++ b/test/session-archive.test.cjs @@ -0,0 +1,90 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { mkdtemp, mkdir, rm, writeFile } = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { loadArchivedSessionIds, watchArchivedSessions } = require("../session-archive"); +const { setTimeout: delay } = require("node:timers/promises"); + +test("reads nested plain and compressed archive filenames, ignoring unrelated files", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "codex-extras-archive-")); + t.after(() => rm(root, { recursive: true, force: true })); + assert.deepEqual([...await loadArchivedSessionIds(path.join(root, "missing"))], []); + const nested = path.join(root, "2026", "10"); + await mkdir(nested, { recursive: true }); + const plain = "019f9c23-f7ec-72c3-8eb6-0018b17bea4f"; + const compressed = "019f9c28-a20f-70f3-9df9-518aaf67a20b"; + await writeFile(path.join(root, `rollout-${plain}.jsonl`), ""); + await writeFile(path.join(nested, `rollout-${compressed.toUpperCase()}.jsonl.zst`), ""); + await writeFile(path.join(root, `rollout-${plain}.jsonl.bak`), ""); + await writeFile(path.join(root, "unrelated.jsonl"), ""); + assert.deepEqual([...await loadArchivedSessionIds(root)].sort(), [plain, compressed].sort()); +}); + +function createWatcherHarness(directory, onChange) { + const watchers = []; + const vscode = { + RelativePattern: class RelativePattern { + constructor(base, pattern) { Object.assign(this, { base, pattern }); } + }, + workspace: { + createFileSystemWatcher(pattern, ignoreCreate, ignoreChange, ignoreDelete) { + const listeners = { create: new Set(), delete: new Set() }; + const subscribe = (type) => (listener) => { + listeners[type].add(listener); + return { dispose: () => listeners[type].delete(listener) }; + }; + const watcher = { + pattern, ignoreCreate, ignoreChange, ignoreDelete, disposed: false, + onDidCreate: subscribe("create"), + onDidDelete: subscribe("delete"), + emit(type, fsPath) { for (const listener of listeners[type]) listener({ fsPath }); }, + dispose() { this.disposed = true; }, + }; + watchers.push(watcher); + return watcher; + }, + }, + }; + return { watcher: watchArchivedSessions(directory, onChange, vscode), watchers }; +} + +test("uses scoped file events, coalesces bursts, and ignores unrelated parent changes", async (t) => { + const directory = path.resolve(os.tmpdir(), "codex-home", "archived_sessions"); + let changes = 0; + const { watcher, watchers: [archive, parent] } = createWatcherHarness(directory, () => changes++); + t.after(() => watcher.dispose()); + assert.deepEqual([archive.pattern.base, archive.pattern.pattern], [directory, "**"]); + assert.deepEqual([parent.pattern.base, parent.pattern.pattern], [path.dirname(directory), "*"]); + assert.deepEqual([archive.ignoreCreate, archive.ignoreChange, archive.ignoreDelete], [false, true, false]); + parent.emit("create", path.join(path.dirname(directory), "state.sqlite-wal")); + await delay(150); + assert.equal(changes, 0); + archive.emit("create", path.join(directory, "2026", "session.jsonl.zst")); + archive.emit("delete", path.join(directory, "2026", "session.jsonl")); + await delay(150); + assert.equal(changes, 1); + await delay(150); + assert.equal(changes, 1, "no periodic refresh when idle"); + parent.emit("delete", directory); + await delay(150); + assert.equal(changes, 2, "whole archive deletion refreshes the list"); + parent.emit("create", directory); + await delay(150); + assert.equal(changes, 3, "whole archive creation refreshes the list"); +}); + +test("disposing watchers cancels a pending refresh and removes event listeners", async () => { + let changes = 0; + const directory = path.resolve(os.tmpdir(), "codex-home", "archived_sessions"); + const { watcher, watchers } = createWatcherHarness(directory, () => changes++); + watchers[0].emit("create", directory); + watcher.dispose(); + watchers[0].emit("delete", directory); + watchers[1].emit("create", directory); + await delay(150); + assert.equal(changes, 0); + assert.ok(watchers.every(({ disposed }) => disposed)); +});