Show and filter work directories in Codex import (#810)

* Add workdir filter to Codex import dialog

* Fix filtered select all in Codex import

---------

Co-authored-by: userZ <spare.nets-0y@icloud.com>
This commit is contained in:
zhaozhao
2026-06-05 21:40:32 +08:00
committed by GitHub
co-authored by userZ
parent 59c29e8423
commit dbc9646d06
4 changed files with 224 additions and 5 deletions
@@ -0,0 +1,150 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/lib/i18n-context'
import { CodexSessionSyncDialog } from './CodexSessionSyncDialog'
import type { CodexLocalSessionSummary } from '@/types/api'
function renderDialog(
sessions: CodexLocalSessionSummary[],
onConfirm = vi.fn(async () => {}),
currentCodexSessionId: string | null = null
) {
const view = render(
<I18nProvider>
<CodexSessionSyncDialog
isOpen={true}
onClose={vi.fn()}
sessions={sessions}
currentCodexSessionId={currentCodexSessionId}
onConfirm={onConfirm}
onRestartCodexDesktop={vi.fn()}
isPending={false}
isRestartingCodexDesktop={false}
isLoading={false}
/>
</I18nProvider>
)
return { ...view, onConfirm }
}
describe('CodexSessionSyncDialog', () => {
afterEach(() => {
cleanup()
})
it('shows the working directory for local Codex sessions', () => {
renderDialog([
{
id: 'codex-session-1',
title: 'Session title',
lastUserMessage: 'Last prompt',
cwd: '/home/user/project',
file: '/home/user/.codex/sessions/session.jsonl',
modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5),
originator: 'codex_cli',
cliVersion: '0.124.0'
}
])
expect(screen.getByText('Working directory')).toBeInTheDocument()
expect(screen.getAllByText('/home/user/project')).toHaveLength(2)
})
it('filters sessions by working directory', () => {
renderDialog([
{
id: 'codex-session-1',
title: 'Project one',
cwd: '/home/user/project-one',
file: '/home/user/.codex/sessions/one.jsonl',
modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5)
},
{
id: 'codex-session-2',
title: 'Project two',
cwd: '/home/user/project-two',
file: '/home/user/.codex/sessions/two.jsonl',
modifiedAt: Date.UTC(2026, 0, 3, 3, 4, 5)
}
])
fireEvent.change(screen.getByLabelText('Work directory'), {
target: { value: '/home/user/project-two' }
})
expect(screen.queryByText('Project one')).not.toBeInTheDocument()
expect(screen.getByText('Project two')).toBeInTheDocument()
})
it('selects only filtered sessions when selecting all', async () => {
const onConfirm = vi.fn(async () => {})
renderDialog([
{
id: 'codex-session-1',
title: 'Project one',
cwd: '/home/user/project-one',
file: '/home/user/.codex/sessions/one.jsonl',
modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5)
},
{
id: 'codex-session-2',
title: 'Project two',
cwd: '/home/user/project-two',
file: '/home/user/.codex/sessions/two.jsonl',
modifiedAt: Date.UTC(2026, 0, 3, 3, 4, 5)
}
], onConfirm)
fireEvent.change(screen.getByLabelText('Work directory'), {
target: { value: '/home/user/project-two' }
})
fireEvent.click(screen.getByRole('button', { name: 'Select all' }))
await waitFor(() => {
expect(screen.getByText('1 sessions selected')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Import' })).toBeEnabled()
})
fireEvent.click(screen.getByRole('button', { name: 'Import' }))
expect(onConfirm).toHaveBeenCalledWith(['codex-session-2'])
})
it('replaces hidden selections when selecting all filtered sessions', async () => {
const onConfirm = vi.fn(async () => {})
renderDialog([
{
id: 'codex-session-1',
title: 'Project one',
cwd: '/home/user/project-one',
file: '/home/user/.codex/sessions/one.jsonl',
modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5)
},
{
id: 'codex-session-2',
title: 'Project two',
cwd: '/home/user/project-two',
file: '/home/user/.codex/sessions/two.jsonl',
modifiedAt: Date.UTC(2026, 0, 3, 3, 4, 5)
}
], onConfirm, 'codex-session-1')
await waitFor(() => {
expect(screen.getByText('1 sessions selected')).toBeInTheDocument()
})
fireEvent.change(screen.getByLabelText('Work directory'), {
target: { value: '/home/user/project-two' }
})
fireEvent.click(screen.getByRole('button', { name: 'Select all' }))
await waitFor(() => {
expect(screen.getByText('1 sessions selected')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Import' })).toBeEnabled()
})
fireEvent.click(screen.getByRole('button', { name: 'Import' }))
expect(onConfirm).toHaveBeenCalledWith(['codex-session-2'])
})
})
+66 -5
View File
@@ -10,6 +10,8 @@ import {
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useTranslation } from '@/lib/use-translation' import { useTranslation } from '@/lib/use-translation'
const ALL_WORKDIR_FILTER = '__all__'
function formatCodexSessionTime(value: number): string | null { function formatCodexSessionTime(value: number): string | null {
if (!Number.isFinite(value)) return null if (!Number.isFinite(value)) return null
return new Date(value).toLocaleString() return new Date(value).toLocaleString()
@@ -24,6 +26,11 @@ function getCodexSessionPreview(session: CodexLocalSessionSummary): string {
return parts.join(' · ') return parts.join(' · ')
} }
function getCodexSessionCwd(session: CodexLocalSessionSummary): string | null {
const cwd = session.cwd?.trim()
return cwd ? cwd : null
}
export function CodexSessionSyncDialog(props: { export function CodexSessionSyncDialog(props: {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
@@ -49,6 +56,7 @@ export function CodexSessionSyncDialog(props: {
} = props } = props
const [selectedSessionIds, setSelectedSessionIds] = useState<string[]>([]) const [selectedSessionIds, setSelectedSessionIds] = useState<string[]>([])
const [hasInitializedSelection, setHasInitializedSelection] = useState(false) const [hasInitializedSelection, setHasInitializedSelection] = useState(false)
const [workdirFilter, setWorkdirFilter] = useState(ALL_WORKDIR_FILTER)
const wasOpenRef = useRef(false) const wasOpenRef = useRef(false)
const sessionIdSet = useMemo( const sessionIdSet = useMemo(
@@ -59,12 +67,25 @@ export function CodexSessionSyncDialog(props: {
() => new Set(selectedSessionIds), () => new Set(selectedSessionIds),
[selectedSessionIds] [selectedSessionIds]
) )
const workdirOptions = useMemo(() => {
const directories = new Set<string>()
for (const session of sessions) {
const cwd = getCodexSessionCwd(session)
if (cwd) directories.add(cwd)
}
return Array.from(directories).sort((a, b) => a.localeCompare(b))
}, [sessions])
const filteredSessions = useMemo(() => {
if (workdirFilter === ALL_WORKDIR_FILTER) return sessions
return sessions.filter((session) => getCodexSessionCwd(session) === workdirFilter)
}, [sessions, workdirFilter])
useEffect(() => { useEffect(() => {
if (isOpen && !wasOpenRef.current) { if (isOpen && !wasOpenRef.current) {
wasOpenRef.current = true wasOpenRef.current = true
setSelectedSessionIds([]) setSelectedSessionIds([])
setHasInitializedSelection(false) setHasInitializedSelection(false)
setWorkdirFilter(ALL_WORKDIR_FILTER)
return return
} }
@@ -72,9 +93,16 @@ export function CodexSessionSyncDialog(props: {
wasOpenRef.current = false wasOpenRef.current = false
setSelectedSessionIds([]) setSelectedSessionIds([])
setHasInitializedSelection(false) setHasInitializedSelection(false)
setWorkdirFilter(ALL_WORKDIR_FILTER)
} }
}, [isOpen]) }, [isOpen])
useEffect(() => {
if (workdirFilter === ALL_WORKDIR_FILTER) return
if (workdirOptions.includes(workdirFilter)) return
setWorkdirFilter(ALL_WORKDIR_FILTER)
}, [workdirFilter, workdirOptions])
useEffect(() => { useEffect(() => {
if (!isOpen || isLoading || hasInitializedSelection) return if (!isOpen || isLoading || hasInitializedSelection) return
@@ -96,7 +124,7 @@ export function CodexSessionSyncDialog(props: {
} }
const selectAll = () => { const selectAll = () => {
setSelectedSessionIds(sessions.map((session) => session.id)) setSelectedSessionIds(filteredSessions.map((session) => session.id))
} }
const clearAll = () => { const clearAll = () => {
@@ -155,13 +183,34 @@ export function CodexSessionSyncDialog(props: {
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={selectAll} onClick={selectAll}
disabled={isPending || isLoading || sessions.length === 0} disabled={isPending || isLoading || filteredSessions.length === 0}
> >
{t('codexSync.confirm.selectAll')} {t('codexSync.confirm.selectAll')}
</Button> </Button>
</div> </div>
</div> </div>
{sessions.length > 0 ? (
<label className="block min-w-0 text-xs text-[var(--app-hint)]">
<span className="mb-1 block">{t('codexSync.confirm.cwdFilter')}</span>
<select
className="h-8 w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-2 text-xs text-[var(--app-fg)] outline-none focus:ring-2 focus:ring-[var(--app-link)]"
value={workdirFilter}
disabled={isPending || isLoading || workdirOptions.length === 0}
onChange={(event) => setWorkdirFilter(event.target.value)}
>
<option value={ALL_WORKDIR_FILTER}>
{t('codexSync.confirm.cwdFilterAll')}
</option>
{workdirOptions.map((directory) => (
<option key={directory} value={directory}>
{directory}
</option>
))}
</select>
</label>
) : null}
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)]"> <div className="max-h-[50vh] overflow-y-auto rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)]">
{isLoading ? ( {isLoading ? (
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]"> <div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">
@@ -171,11 +220,17 @@ export function CodexSessionSyncDialog(props: {
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]"> <div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">
{t('codexSync.confirm.empty')} {t('codexSync.confirm.empty')}
</div> </div>
) : filteredSessions.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">
{t('codexSync.confirm.emptyForWorkdir')}
</div>
) : ( ) : (
<div className="divide-y divide-[var(--app-border)]"> <div className="divide-y divide-[var(--app-border)]">
{sessions.map((session) => { {filteredSessions.map((session) => {
const checked = selectedSessionIdSet.has(session.id) const checked = selectedSessionIdSet.has(session.id)
const time = formatCodexSessionTime(session.modifiedAt) const time = formatCodexSessionTime(session.modifiedAt)
const preview = getCodexSessionPreview(session)
const cwd = getCodexSessionCwd(session)
return ( return (
<label <label
key={session.id} key={session.id}
@@ -199,9 +254,15 @@ export function CodexSessionSyncDialog(props: {
</span> </span>
) : null} ) : null}
</div> </div>
{getCodexSessionPreview(session) ? ( {preview ? (
<div className="mt-0.5 truncate text-xs text-[var(--app-hint)]"> <div className="mt-0.5 truncate text-xs text-[var(--app-hint)]">
{getCodexSessionPreview(session)} {preview}
</div>
) : null}
{cwd ? (
<div className="mt-0.5 flex min-w-0 items-center gap-1 text-[11px] text-[var(--app-hint)]">
<span className="shrink-0">{t('codexSync.confirm.cwd')}</span>
<span className="min-w-0 truncate font-mono" title={cwd}>{cwd}</span>
</div> </div>
) : null} ) : null}
{time ? ( {time ? (
+4
View File
@@ -62,6 +62,10 @@ export default {
'codexSync.confirm.selectedCount': '{n} sessions selected', 'codexSync.confirm.selectedCount': '{n} sessions selected',
'codexSync.confirm.empty': 'No local Codex sessions found', 'codexSync.confirm.empty': 'No local Codex sessions found',
'codexSync.confirm.current': 'Linked', 'codexSync.confirm.current': 'Linked',
'codexSync.confirm.cwd': 'Working directory',
'codexSync.confirm.cwdFilter': 'Work directory',
'codexSync.confirm.cwdFilterAll': 'All work directories',
'codexSync.confirm.emptyForWorkdir': 'No Codex sessions in this work directory',
'codexSync.confirm.confirm': 'Import', 'codexSync.confirm.confirm': 'Import',
'codexSync.confirm.confirming': 'Importing…', 'codexSync.confirm.confirming': 'Importing…',
'codexSync.confirm.loading': 'Loading local Codex sessions…', 'codexSync.confirm.loading': 'Loading local Codex sessions…',
+4
View File
@@ -62,6 +62,10 @@ export default {
'codexSync.confirm.selectedCount': '已选择 {n} 个会话', 'codexSync.confirm.selectedCount': '已选择 {n} 个会话',
'codexSync.confirm.empty': '未找到本地 Codex 会话', 'codexSync.confirm.empty': '未找到本地 Codex 会话',
'codexSync.confirm.current': '当前关联', 'codexSync.confirm.current': '当前关联',
'codexSync.confirm.cwd': '工作目录',
'codexSync.confirm.cwdFilter': '工作目录',
'codexSync.confirm.cwdFilterAll': '全部工作目录',
'codexSync.confirm.emptyForWorkdir': '此工作目录下没有 Codex 会话',
'codexSync.confirm.confirm': '导入', 'codexSync.confirm.confirm': '导入',
'codexSync.confirm.confirming': '导入中…', 'codexSync.confirm.confirming': '导入中…',
'codexSync.confirm.loading': '正在读取本地 Codex 会话…', 'codexSync.confirm.loading': '正在读取本地 Codex 会话…',