mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
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:
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
|
||||
const ALL_WORKDIR_FILTER = '__all__'
|
||||
|
||||
function formatCodexSessionTime(value: number): string | null {
|
||||
if (!Number.isFinite(value)) return null
|
||||
return new Date(value).toLocaleString()
|
||||
@@ -24,6 +26,11 @@ function getCodexSessionPreview(session: CodexLocalSessionSummary): string {
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function getCodexSessionCwd(session: CodexLocalSessionSummary): string | null {
|
||||
const cwd = session.cwd?.trim()
|
||||
return cwd ? cwd : null
|
||||
}
|
||||
|
||||
export function CodexSessionSyncDialog(props: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
@@ -49,6 +56,7 @@ export function CodexSessionSyncDialog(props: {
|
||||
} = props
|
||||
const [selectedSessionIds, setSelectedSessionIds] = useState<string[]>([])
|
||||
const [hasInitializedSelection, setHasInitializedSelection] = useState(false)
|
||||
const [workdirFilter, setWorkdirFilter] = useState(ALL_WORKDIR_FILTER)
|
||||
const wasOpenRef = useRef(false)
|
||||
|
||||
const sessionIdSet = useMemo(
|
||||
@@ -59,12 +67,25 @@ export function CodexSessionSyncDialog(props: {
|
||||
() => new Set(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(() => {
|
||||
if (isOpen && !wasOpenRef.current) {
|
||||
wasOpenRef.current = true
|
||||
setSelectedSessionIds([])
|
||||
setHasInitializedSelection(false)
|
||||
setWorkdirFilter(ALL_WORKDIR_FILTER)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -72,9 +93,16 @@ export function CodexSessionSyncDialog(props: {
|
||||
wasOpenRef.current = false
|
||||
setSelectedSessionIds([])
|
||||
setHasInitializedSelection(false)
|
||||
setWorkdirFilter(ALL_WORKDIR_FILTER)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (workdirFilter === ALL_WORKDIR_FILTER) return
|
||||
if (workdirOptions.includes(workdirFilter)) return
|
||||
setWorkdirFilter(ALL_WORKDIR_FILTER)
|
||||
}, [workdirFilter, workdirOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || isLoading || hasInitializedSelection) return
|
||||
|
||||
@@ -96,7 +124,7 @@ export function CodexSessionSyncDialog(props: {
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedSessionIds(sessions.map((session) => session.id))
|
||||
setSelectedSessionIds(filteredSessions.map((session) => session.id))
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
@@ -155,13 +183,34 @@ export function CodexSessionSyncDialog(props: {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={selectAll}
|
||||
disabled={isPending || isLoading || sessions.length === 0}
|
||||
disabled={isPending || isLoading || filteredSessions.length === 0}
|
||||
>
|
||||
{t('codexSync.confirm.selectAll')}
|
||||
</Button>
|
||||
</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)]">
|
||||
{isLoading ? (
|
||||
<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)]">
|
||||
{t('codexSync.confirm.empty')}
|
||||
</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)]">
|
||||
{sessions.map((session) => {
|
||||
{filteredSessions.map((session) => {
|
||||
const checked = selectedSessionIdSet.has(session.id)
|
||||
const time = formatCodexSessionTime(session.modifiedAt)
|
||||
const preview = getCodexSessionPreview(session)
|
||||
const cwd = getCodexSessionCwd(session)
|
||||
return (
|
||||
<label
|
||||
key={session.id}
|
||||
@@ -199,9 +254,15 @@ export function CodexSessionSyncDialog(props: {
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{getCodexSessionPreview(session) ? (
|
||||
{preview ? (
|
||||
<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>
|
||||
) : null}
|
||||
{time ? (
|
||||
|
||||
@@ -62,6 +62,10 @@ export default {
|
||||
'codexSync.confirm.selectedCount': '{n} sessions selected',
|
||||
'codexSync.confirm.empty': 'No local Codex sessions found',
|
||||
'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.confirming': 'Importing…',
|
||||
'codexSync.confirm.loading': 'Loading local Codex sessions…',
|
||||
|
||||
@@ -62,6 +62,10 @@ export default {
|
||||
'codexSync.confirm.selectedCount': '已选择 {n} 个会话',
|
||||
'codexSync.confirm.empty': '未找到本地 Codex 会话',
|
||||
'codexSync.confirm.current': '当前关联',
|
||||
'codexSync.confirm.cwd': '工作目录',
|
||||
'codexSync.confirm.cwdFilter': '工作目录',
|
||||
'codexSync.confirm.cwdFilterAll': '全部工作目录',
|
||||
'codexSync.confirm.emptyForWorkdir': '此工作目录下没有 Codex 会话',
|
||||
'codexSync.confirm.confirm': '导入',
|
||||
'codexSync.confirm.confirming': '导入中…',
|
||||
'codexSync.confirm.loading': '正在读取本地 Codex 会话…',
|
||||
|
||||
Reference in New Issue
Block a user