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 { 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 ? (
|
||||||
|
|||||||
@@ -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…',
|
||||||
|
|||||||
@@ -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 会话…',
|
||||||
|
|||||||
Reference in New Issue
Block a user