diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 8fbecaab..da18c70e 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -398,6 +398,20 @@ export const jaOverrides = { 'authFiles.runtimeDeleteError': '実行時認証ファイルはディスクから削除できません', 'authFiles.deleteConfirm': '「{name}」を削除しますか?', 'authFiles.deleted': '認証ファイルを削除しました', + 'authFiles.batch.selected': '{count} 件選択中', + 'authFiles.batch.selectVisible': 'すべて選択', + 'authFiles.batch.deselectVisible': '表示中の選択を解除', + 'authFiles.batch.clear': '選択を解除', + 'authFiles.batch.selectCredential': '{name} を選択', + 'authFiles.batch.enable': '有効化({count})', + 'authFiles.batch.disable': '無効化({count})', + 'authFiles.batch.delete': '削除({count})', + 'authFiles.batch.enabled': '認証ファイルを {count} 件有効化しました', + 'authFiles.batch.disabled': '認証ファイルを {count} 件無効化しました', + 'authFiles.batch.deleted': '認証ファイルを {count} 件削除しました', + 'authFiles.batch.failed': '{count} 件の一括操作に失敗しました:{errors}', + 'authFiles.batch.deleteConfirm': '選択した認証ファイル {count} 件を削除しますか?', + 'authFiles.batch.runtimeSkipped': '実行時認証情報 {count} 件はスキップされます。', 'common.copyFailed': 'コピーに失敗しました', 'authFiles.title': '認証ファイル', 'authFiles.summary': 'ファイル {files} 件 · 無効 {disabled} 件', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e39efc9b..b5a71c46 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -398,6 +398,20 @@ export const en: Record = { 'authFiles.runtimeDeleteError': 'Runtime authentication files cannot be deleted from disk', 'authFiles.deleteConfirm': 'Delete “{name}”?', 'authFiles.deleted': 'Authentication file deleted', + 'authFiles.batch.selected': '{count} selected', + 'authFiles.batch.selectVisible': 'Select all', + 'authFiles.batch.deselectVisible': 'Deselect Visible', + 'authFiles.batch.clear': 'Clear selection', + 'authFiles.batch.selectCredential': 'Select {name}', + 'authFiles.batch.enable': 'Enable ({count})', + 'authFiles.batch.disable': 'Disable ({count})', + 'authFiles.batch.delete': 'Delete ({count})', + 'authFiles.batch.enabled': 'Enabled {count} authentication files', + 'authFiles.batch.disabled': 'Disabled {count} authentication files', + 'authFiles.batch.deleted': 'Deleted {count} authentication files', + 'authFiles.batch.failed': '{count} batch operations failed: {errors}', + 'authFiles.batch.deleteConfirm': 'Delete the {count} selected authentication files?', + 'authFiles.batch.runtimeSkipped': '{count} runtime credentials will be skipped.', 'common.copyFailed': 'Copy failed', 'authFiles.title': 'Authentication Files', 'authFiles.summary': '{files} files · {disabled} disabled', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 9ecde0d6..12fe79ba 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -396,6 +396,20 @@ export const zhCN = { 'authFiles.runtimeDeleteError': '运行时认证文件不能从磁盘删除', 'authFiles.deleteConfirm': '确定删除“{name}”吗?', 'authFiles.deleted': '认证文件已删除', + 'authFiles.batch.selected': '已选择 {count} 个', + 'authFiles.batch.selectVisible': '全选', + 'authFiles.batch.deselectVisible': '取消当前结果', + 'authFiles.batch.clear': '清除选择', + 'authFiles.batch.selectCredential': '选择 {name}', + 'authFiles.batch.enable': '启用({count})', + 'authFiles.batch.disable': '停用({count})', + 'authFiles.batch.delete': '删除({count})', + 'authFiles.batch.enabled': '已启用 {count} 个认证文件', + 'authFiles.batch.disabled': '已停用 {count} 个认证文件', + 'authFiles.batch.deleted': '已删除 {count} 个认证文件', + 'authFiles.batch.failed': '{count} 个批次操作失败:{errors}', + 'authFiles.batch.deleteConfirm': '确定删除选择的 {count} 个认证文件吗?', + 'authFiles.batch.runtimeSkipped': '将跳过 {count} 个运行时凭据。', 'common.copyFailed': '复制失败', 'authFiles.title': '认证文件', 'authFiles.summary': '{files} 个文件 · {disabled} 个停用', diff --git a/src/pages/AuthFileManagementPage.tsx b/src/pages/AuthFileManagementPage.tsx index 62c2a069..e7096271 100644 --- a/src/pages/AuthFileManagementPage.tsx +++ b/src/pages/AuthFileManagementPage.tsx @@ -43,6 +43,7 @@ import { useQuotaCache, } from '../services/quotaCache'; import { + authFilesForBatchAction, authFileName, dedupeAuthFiles, isRuntimeOnlyAuthFile, @@ -154,6 +155,7 @@ export function AuthFileManagementPage() { const [error, setError] = useState(''); const [notice, setNotice] = useState(''); const [copied, setCopied] = useState(''); + const [selectedNames, setSelectedNames] = useState>(new Set()); const [priorityEditor, setPriorityEditor] = useState(null); const [oauthModelProvider, setOauthModelProvider] = useState(''); const [oauthModelProviderLabel, setOauthModelProviderLabel] = useState(''); @@ -166,6 +168,7 @@ export function AuthFileManagementPage() { const [oauthModelError, setOauthModelError] = useState(''); const quotas = useQuotaCache(); const fileInputRef = useRef(null); + const selectVisibleRef = useRef(null); const oauthModelRequestRef = useRef(0); const loadFiles = useCallback(async () => { @@ -175,6 +178,10 @@ export function AuthFileManagementPage() { const payload = await managementApi.get('/auth-files'); const nextFiles = dedupeAuthFiles(responseList(payload, 'files')); setFiles(nextFiles); + const validNames = new Set(nextFiles.map(fileName)); + setSelectedNames((current) => new Set( + Array.from(current).filter((name) => validNames.has(name)), + )); const validQuotaKeys = new Set(nextFiles.map(quotaKey)); pruneQuotaCache(validQuotaKeys); updateQuotaCache((current) => { @@ -327,6 +334,53 @@ export function AuthFileManagementPage() { }); }, [files, filter, providerFilter, statusFilter]); + const selectedFiles = useMemo( + () => files.filter((file) => selectedNames.has(fileName(file))), + [files, selectedNames], + ); + const enableTargets = useMemo( + () => authFilesForBatchAction(files, selectedNames, 'enable'), + [files, selectedNames], + ); + const disableTargets = useMemo( + () => authFilesForBatchAction(files, selectedNames, 'disable'), + [files, selectedNames], + ); + const deleteTargets = useMemo( + () => authFilesForBatchAction(files, selectedNames, 'delete'), + [files, selectedNames], + ); + const visibleNames = visibleFiles.map(fileName); + const selectedVisibleCount = visibleNames.filter((name) => selectedNames.has(name)).length; + const allVisibleSelected = visibleNames.length > 0 && selectedVisibleCount === visibleNames.length; + const someVisibleSelected = selectedVisibleCount > 0 && !allVisibleSelected; + + useEffect(() => { + if (selectVisibleRef.current) { + selectVisibleRef.current.indeterminate = someVisibleSelected; + } + }, [someVisibleSelected]); + + const toggleFileSelection = (name: string) => { + setSelectedNames((current) => { + const next = new Set(current); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }; + + const toggleVisibleSelection = () => { + setSelectedNames((current) => { + const next = new Set(current); + visibleNames.forEach((name) => { + if (allVisibleSelected) next.delete(name); + else next.add(name); + }); + return next; + }); + }; + const handleUpload = async (event: ChangeEvent) => { const selected = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = ''; @@ -459,6 +513,60 @@ export function AuthFileManagementPage() { } }; + const runBatchAction = async ( + action: 'enable' | 'disable' | 'delete', + targets: AuthFile[], + ) => { + if (busy || targets.length === 0) return; + if (action === 'delete') { + const skipped = selectedFiles.length - targets.length; + const detail = skipped > 0 ? `\n\n${t('authFiles.batch.runtimeSkipped', { count: skipped })}` : ''; + if (!window.confirm(`${t('authFiles.batch.deleteConfirm', { count: targets.length })}${detail}`)) return; + } + + setBusy(true); + setError(''); + setNotice(''); + let completed = 0; + const failures: string[] = []; + for (const file of targets) { + const name = fileName(file); + try { + if (action === 'delete') { + await managementApi.delete('/auth-files', { query: { name } }); + } else { + await managementApi.patch('/auth-files/status', { + name, + disabled: action === 'disable', + }); + } + completed += 1; + } catch (requestError) { + failures.push(`${name}: ${String(requestError)}`); + } + } + + try { + await loadFiles(); + if (completed > 0) { + const messageKey = action === 'enable' + ? 'authFiles.batch.enabled' + : action === 'disable' + ? 'authFiles.batch.disabled' + : 'authFiles.batch.deleted'; + setNotice(t(messageKey, { count: completed })); + } + if (failures.length > 0) { + setError(t('authFiles.batch.failed', { + count: failures.length, + errors: failures.join('; '), + })); + } + } finally { + setBusy(false); + } + }; + const disabledCount = files.filter((file) => readBoolean(file, 'disabled')).length; const runtimeCount = files.filter(isRuntimeOnly).length; @@ -503,6 +611,31 @@ export function AuthFileManagementPage() { + {files.length > 0 ? ( +
0 ? 'active' : ''}`}> + + {t('authFiles.batch.selected', { count: selectedNames.size })} + {selectedNames.size > 0 ? ( +
+ + + + +
+ ) : null} +
+ ) : null} + {loading ? (
{t('authFiles.loading')}
) : visibleFiles.length === 0 ? ( @@ -515,7 +648,10 @@ export function AuthFileManagementPage() { const disabled = readBoolean(file, 'disabled'); const priority = parseAuthFilePriority(file.priority) ?? 0; return ( -
+
+
{name}{statusText(file)}
diff --git a/src/services/authFiles.ts b/src/services/authFiles.ts index ac147ef9..efb9eee4 100644 --- a/src/services/authFiles.ts +++ b/src/services/authFiles.ts @@ -3,6 +3,7 @@ import { getCurrentLocale, translate } from '../i18n'; export type AuthFileRecord = Record; export type AuthFileSnapshot = Map; +export type AuthFileBatchAction = 'enable' | 'disable' | 'delete'; export const authFileName = (file: AuthFileRecord) => readString(file, 'name') || translate(getCurrentLocale(), 'authFiles.unnamed'); @@ -10,6 +11,17 @@ export const authFileName = (file: AuthFileRecord) => export const isRuntimeOnlyAuthFile = (file: AuthFileRecord) => readBoolean(file, 'runtime_only', 'runtimeOnly'); +export const authFilesForBatchAction = ( + files: AuthFileRecord[], + selectedNames: ReadonlySet, + action: AuthFileBatchAction, +) => files.filter((file) => { + if (!selectedNames.has(authFileName(file))) return false; + if (action === 'delete') return !isRuntimeOnlyAuthFile(file); + const disabled = readBoolean(file, 'disabled'); + return action === 'enable' ? disabled : !disabled; +}); + export const parseAuthFilePriority = (value: unknown): number | undefined => { if (typeof value === 'number') { return Number.isSafeInteger(value) ? value : undefined; diff --git a/src/styles.css b/src/styles.css index c8950abc..c2074177 100644 --- a/src/styles.css +++ b/src/styles.css @@ -6617,6 +6617,82 @@ textarea:focus-visible { color: var(--theme-5f5a53); } +.auth-files-batch-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 12px; + margin-top: 12px; + padding: 8px 10px; + border: 1px solid var(--theme-e3e1db); + border-radius: 8px; + background: var(--theme-f8f6f1); +} + +.auth-files-batch-toolbar.active { + border-color: var(--ui-accent); + background: var(--ui-accent-soft); +} + +.auth-files-batch-toolbar > strong { + color: var(--theme-5f5a53); + font-size: var(--font-size-body); +} + +.auth-files-select-visible { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 30px; + padding: 0 3px; + color: var(--theme-433f39); + font-size: var(--font-size-body); + font-weight: 700; + cursor: pointer; + user-select: none; +} + +.auth-files-select-visible input { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--ui-accent); + cursor: pointer; +} + +.auth-files-select-visible.disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.auth-files-select-visible input:disabled { + cursor: not-allowed; +} + +.auth-files-batch-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + margin-left: auto; +} + +.auth-file-select { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 32px; + cursor: pointer; +} + +.auth-file-select input { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--ui-accent); +} + .real-auth-file-list { margin-top: 14px; border-top: 1px solid var(--theme-e3e1db); @@ -6624,7 +6700,7 @@ textarea:focus-visible { .real-auth-file-row { display: grid; - grid-template-columns: 34px minmax(0, 1.2fr) minmax(160px, 0.8fr) auto; + grid-template-columns: 26px 34px minmax(0, 1.2fr) minmax(160px, 0.8fr) auto; align-items: center; gap: 14px; min-height: 76px; @@ -6632,9 +6708,13 @@ textarea:focus-visible { border-bottom: 1px solid var(--theme-eeece6); } +.real-auth-file-row.selected { + box-shadow: inset 3px 0 0 var(--ui-accent); +} + .auth-file-quota { display: flex; - grid-column: 2 / -1; + grid-column: 3 / -1; align-items: center; flex-wrap: wrap; gap: 5px 8px; @@ -7500,7 +7580,7 @@ textarea:focus-visible { } .real-auth-file-row { - grid-template-columns: 34px minmax(0, 1fr) auto; + grid-template-columns: 26px 34px minmax(0, 1fr) auto; } .provider-row-meta, @@ -7904,7 +7984,7 @@ textarea:focus-visible { } .real-auth-file-row { - grid-template-columns: 30px minmax(0, 1fr); + grid-template-columns: 26px 30px minmax(0, 1fr); } .provider-row-actions { @@ -7913,7 +7993,7 @@ textarea:focus-visible { } .auth-file-actions { - grid-column: 2; + grid-column: 3; flex-wrap: wrap; justify-content: flex-start; } diff --git a/tests/authFiles.test.ts b/tests/authFiles.test.ts index c782b1a4..f6f87e8e 100644 --- a/tests/authFiles.test.ts +++ b/tests/authFiles.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test'; import { + authFilesForBatchAction, changedOAuthAuthFileNames, dedupeAuthFiles, normalizeAuthFilePriorityInput, @@ -65,3 +66,28 @@ describe('authentication file priority', () => { ], 'codex')).toEqual(['codex-old.json', 'codex-new.json']); }); }); + +describe('authentication file batch management', () => { + const files = [ + { name: 'enabled.json', disabled: false }, + { name: 'disabled.json', disabled: true }, + { name: 'runtime.json', disabled: false, runtime_only: true }, + { name: 'unselected.json', disabled: false }, + ]; + const selected = new Set(['enabled.json', 'disabled.json', 'runtime.json']); + + it('only enables selected credentials that are currently disabled', () => { + expect(authFilesForBatchAction(files, selected, 'enable').map((file) => file.name)) + .toEqual(['disabled.json']); + }); + + it('only disables selected credentials that are currently enabled', () => { + expect(authFilesForBatchAction(files, selected, 'disable').map((file) => file.name)) + .toEqual(['enabled.json', 'runtime.json']); + }); + + it('excludes runtime credentials from batch deletion', () => { + expect(authFilesForBatchAction(files, selected, 'delete').map((file) => file.name)) + .toEqual(['enabled.json', 'disabled.json']); + }); +});