✨ feat(vscode): 会话管理支持导入已 /rename 的命名会话
This commit is contained in:
1 parent
7c67d220db
commit
dc522064ce
10 files changed
+438
-9
No files matched your search
@@ -0,0 +1,124 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
extractLastCustomTitle,
|
||||
listImportableNamedSessions,
|
||||
} from './importableNamedSessions'
|
||||
import { writeManagedSessions } from './managedStore'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop()
|
||||
if (dir)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function tempDir(prefix: string): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), prefix))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('extractLastCustomTitle', () => {
|
||||
it('多条 custom-title 时取最后一条', () => {
|
||||
const text = [
|
||||
JSON.stringify({ type: 'custom-title', customTitle: '旧名', sessionId: 'a' }),
|
||||
JSON.stringify({ type: 'user', message: 'hi' }),
|
||||
JSON.stringify({ type: 'custom-title', customTitle: '新名', sessionId: 'a' }),
|
||||
'',
|
||||
].join('\n')
|
||||
expect(extractLastCustomTitle(text)).toBe('新名')
|
||||
})
|
||||
|
||||
it('无 custom-title 返回 null', () => {
|
||||
const text = [
|
||||
JSON.stringify({ type: 'user', message: 'hi' }),
|
||||
JSON.stringify({ type: 'assistant', message: 'yo' }),
|
||||
].join('\n')
|
||||
expect(extractLastCustomTitle(text)).toBeNull()
|
||||
})
|
||||
|
||||
it('仅 ai-title 忽略,返回 null', () => {
|
||||
const text = JSON.stringify({ type: 'ai-title', title: 'AI 起的名' })
|
||||
expect(extractLastCustomTitle(text)).toBeNull()
|
||||
})
|
||||
|
||||
it('customTitle 仅空白时忽略', () => {
|
||||
const text = [
|
||||
JSON.stringify({ type: 'custom-title', customTitle: ' ' }),
|
||||
JSON.stringify({ type: 'custom-title', customTitle: '\t' }),
|
||||
].join('\n')
|
||||
expect(extractLastCustomTitle(text)).toBeNull()
|
||||
})
|
||||
|
||||
it('trim 非空 customTitle', () => {
|
||||
const text = JSON.stringify({ type: 'custom-title', customTitle: ' 有空格 ' })
|
||||
expect(extractLastCustomTitle(text)).toBe('有空格')
|
||||
})
|
||||
|
||||
it('非法 json 行跳过', () => {
|
||||
const text = [
|
||||
'not-json',
|
||||
JSON.stringify({ type: 'custom-title', customTitle: 'ok' }),
|
||||
'{broken',
|
||||
].join('\n')
|
||||
expect(extractLastCustomTitle(text)).toBe('ok')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listImportableNamedSessions', () => {
|
||||
it('过滤已在 managed store 的 id,按 mtime 降序', async () => {
|
||||
const workspaceRoot = tempDir('importable-ws-')
|
||||
const projectsDir = tempDir('importable-proj-')
|
||||
|
||||
const older = path.join(projectsDir, 'sess-old.jsonl')
|
||||
const newer = path.join(projectsDir, 'sess-new.jsonl')
|
||||
const managed = path.join(projectsDir, 'sess-managed.jsonl')
|
||||
const unnamed = path.join(projectsDir, 'sess-unnamed.jsonl')
|
||||
|
||||
writeFileSync(older, `${JSON.stringify({ type: 'custom-title', customTitle: '旧会话' })}\n`)
|
||||
writeFileSync(newer, `${JSON.stringify({ type: 'custom-title', customTitle: '新会话' })}\n`)
|
||||
writeFileSync(managed, `${JSON.stringify({ type: 'custom-title', customTitle: '已登记' })}\n`)
|
||||
writeFileSync(unnamed, `${JSON.stringify({ type: 'user', message: 'no title' })}\n`)
|
||||
|
||||
// 固定 mtime:newer 更晚
|
||||
const oldMs = Date.now() - 60_000
|
||||
const newMs = Date.now() - 10_000
|
||||
utimesSync(older, new Date(oldMs), new Date(oldMs))
|
||||
utimesSync(newer, new Date(newMs), new Date(newMs))
|
||||
utimesSync(managed, new Date(newMs), new Date(newMs))
|
||||
|
||||
mkdirSync(path.join(workspaceRoot, '.spx'), { recursive: true })
|
||||
await writeManagedSessions(workspaceRoot, {
|
||||
sessions: [
|
||||
{ id: 'sess-managed', name: '已登记', createdAt: 1 },
|
||||
],
|
||||
})
|
||||
|
||||
// 子目录内 jsonl 不应被扫到
|
||||
const sub = path.join(projectsDir, 'subagents')
|
||||
mkdirSync(sub)
|
||||
writeFileSync(
|
||||
path.join(sub, 'agent.jsonl'),
|
||||
`${JSON.stringify({ type: 'custom-title', customTitle: '子代理' })}\n`,
|
||||
)
|
||||
|
||||
const result = await listImportableNamedSessions(workspaceRoot, projectsDir)
|
||||
expect(result.map(r => r.id)).toEqual(['sess-new', 'sess-old'])
|
||||
expect(result[0]).toMatchObject({ id: 'sess-new', name: '新会话' })
|
||||
expect(result[1]).toMatchObject({ id: 'sess-old', name: '旧会话' })
|
||||
expect(result[0].mtimeMs).toBeGreaterThan(result[1].mtimeMs)
|
||||
})
|
||||
|
||||
it('空目录返回 []', async () => {
|
||||
const workspaceRoot = tempDir('importable-empty-ws-')
|
||||
const projectsDir = tempDir('importable-empty-proj-')
|
||||
const result = await listImportableNamedSessions(workspaceRoot, projectsDir)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 从 Claude projects 目录扫描已 /rename(jsonl 含 custom-title)且尚未登记到
|
||||
* managed store 的会话,供「导入」候选列表使用。
|
||||
*/
|
||||
|
||||
import { promises as fsp } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { projectsDirFor } from '../cc/sessionWatcher'
|
||||
import { readManagedSessions } from './managedStore'
|
||||
|
||||
export interface ImportableNamedSession {
|
||||
id: string
|
||||
name: string
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
/** 从 jsonl 文本提取最后一条 custom-title(trim 后非空),否则 null */
|
||||
export function extractLastCustomTitle(jsonlText: string): string | null {
|
||||
let last: string | null = null
|
||||
const lines = jsonlText.split(/\r?\n/)
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed)
|
||||
continue
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(trimmed)
|
||||
}
|
||||
catch {
|
||||
continue
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object')
|
||||
continue
|
||||
const rec = parsed as { type?: unknown, customTitle?: unknown }
|
||||
if (rec.type !== 'custom-title')
|
||||
continue
|
||||
if (typeof rec.customTitle !== 'string')
|
||||
continue
|
||||
const title = rec.customTitle.trim()
|
||||
if (!title)
|
||||
continue
|
||||
last = title
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出可导入命名会话:扫 projectsDir 顶层 *.jsonl,过滤已在 managed 的 id,mtime 降序。
|
||||
* `projectsDir` 可选,便于测试注入;默认 `projectsDirFor(workspaceRoot)`。
|
||||
*/
|
||||
export async function listImportableNamedSessions(
|
||||
workspaceRoot: string,
|
||||
projectsDir?: string,
|
||||
): Promise<ImportableNamedSession[]> {
|
||||
const dir = projectsDir ?? projectsDirFor(workspaceRoot)
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = await fsp.readdir(dir, { withFileTypes: true })
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const managed = await readManagedSessions(workspaceRoot)
|
||||
const managedIds = new Set(managed.sessions.map(s => s.id))
|
||||
|
||||
const results: ImportableNamedSession[] = []
|
||||
for (const ent of entries) {
|
||||
if (!ent.isFile() || !ent.name.endsWith('.jsonl'))
|
||||
continue
|
||||
const id = ent.name.slice(0, -'.jsonl'.length)
|
||||
if (!id || managedIds.has(id))
|
||||
continue
|
||||
const filePath = path.join(dir, ent.name)
|
||||
let text: string
|
||||
let mtimeMs: number
|
||||
try {
|
||||
const [raw, st] = await Promise.all([
|
||||
fsp.readFile(filePath, 'utf8'),
|
||||
fsp.stat(filePath),
|
||||
])
|
||||
text = raw
|
||||
mtimeMs = st.mtimeMs
|
||||
}
|
||||
catch {
|
||||
continue
|
||||
}
|
||||
const name = extractLastCustomTitle(text)
|
||||
if (!name)
|
||||
continue
|
||||
results.push({ id, name, mtimeMs })
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||
return results
|
||||
}
|
||||
Reference in new issue
Block a user