Add i18n foundation and translations (#1027)

* feat: add i18n foundation

* feat: localize sessions sidebar

* Localize multirun/scheduled tasks and fix dialog dropdown interactions

* localize git sidebar surface and add zh-CN keys

* feat(ui): localize context panel, diff/plan views, and context sidebar content

* fix(config): resolve user config home via fs/home before embedded home

* localize header/chat UI and complete model/worktree panel strings

* localize worktree + github issue/pr dialog flows

* localize settings sections and split settings i18n dictionaries

* localize additional settings sections and sidebars

* localize more settings pages and dialogs

* fix settings select trigger localization

* localize tunnel settings ui surface

* localize additional settings sections

* localize keyboard shortcuts labels in settings

* localize terminal and utility dialogs surfaces

* feat(i18n): localize remaining UI strings

* Add Ukrainian locale

* Add Spanish locale

* Add Brazilian Portuguese locale

* Polish locale translations
This commit is contained in:
Bohdan Triapitsyn
2026-04-26 14:03:39 +03:00
committed by GitHub
parent 87db2ea210
commit 7d7285655d
198 changed files with 24173 additions and 4365 deletions
+56 -44
View File
@@ -28,6 +28,8 @@ import { useDeviceInfo } from '@/lib/device';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
import { sessionEvents } from '@/lib/sessionEvents';
import { useI18n } from '@/lib/i18n';
import type { I18nKey } from '@/lib/i18n/store';
// Minimum width for side-by-side diff view (px)
const SIDE_BY_SIDE_MIN_WIDTH = 1100;
@@ -52,9 +54,10 @@ type FileEntry = GitStatus['files'][number] & {
type DiffData = { original: string; modified: string; isBinary?: boolean };
const BinaryDiffPlaceholder = React.memo(() => {
const { t } = useI18n();
return (
<div className="rounded-lg border border-border/60 bg-background px-3 py-2">
<div className="typography-meta text-muted-foreground">Content of this file cannot be viewed.</div>
<div className="typography-meta text-muted-foreground">{t('diffView.binary.unavailable')}</div>
</div>
);
});
@@ -64,34 +67,34 @@ type DiffTabViewMode = 'single' | 'stacked';
type ChangeDescriptor = {
code: string;
color: string;
description: string;
descriptionKey: I18nKey;
};
const CHANGE_DESCRIPTORS: Record<string, ChangeDescriptor> = {
'?': { code: '?', color: 'var(--status-info)', description: 'Untracked file' },
A: { code: 'A', color: 'var(--status-success)', description: 'New file' },
D: { code: 'D', color: 'var(--status-error)', description: 'Deleted file' },
R: { code: 'R', color: 'var(--status-info)', description: 'Renamed file' },
C: { code: 'C', color: 'var(--status-info)', description: 'Copied file' },
M: { code: 'M', color: 'var(--status-warning)', description: 'Modified file' },
'?': { code: '?', color: 'var(--status-info)', descriptionKey: 'diffView.change.untracked' },
A: { code: 'A', color: 'var(--status-success)', descriptionKey: 'diffView.change.new' },
D: { code: 'D', color: 'var(--status-error)', descriptionKey: 'diffView.change.deleted' },
R: { code: 'R', color: 'var(--status-info)', descriptionKey: 'diffView.change.renamed' },
C: { code: 'C', color: 'var(--status-info)', descriptionKey: 'diffView.change.copied' },
M: { code: 'M', color: 'var(--status-warning)', descriptionKey: 'diffView.change.modified' },
};
const DEFAULT_CHANGE_DESCRIPTOR = CHANGE_DESCRIPTORS.M;
const DIFF_VIEW_MODE_OPTIONS: Array<{
value: DiffTabViewMode;
label: string;
description: string;
labelKey: I18nKey;
descriptionKey: I18nKey;
}> = [
{
value: 'single',
label: 'Single file',
description: 'Show one file at a time',
labelKey: 'diffView.mode.single.label',
descriptionKey: 'diffView.mode.single.description',
},
{
value: 'stacked',
label: 'All files',
description: 'Stack all modified files together',
labelKey: 'diffView.mode.stacked.label',
descriptionKey: 'diffView.mode.stacked.description',
},
];
@@ -205,6 +208,7 @@ const FileSelector = React.memo<FileSelectorProps>(({
mode,
onModeChange,
}) => {
const { t } = useI18n();
const getLabel = React.useCallback((path: string) => {
if (!isMobile) return path;
const lastSlash = path.lastIndexOf('/');
@@ -226,7 +230,7 @@ const FileSelector = React.memo<FileSelectorProps>(({
{formatDiffTotals(selectedFileEntry.insertions, selectedFileEntry.deletions)}
</div>
) : (
<span className="text-muted-foreground">Select file</span>
<span className="text-muted-foreground">{t('diffView.selector.selectFile')}</span>
)}
<RiArrowDownSLine className="size-4 opacity-50" />
</button>
@@ -235,7 +239,7 @@ const FileSelector = React.memo<FileSelectorProps>(({
{showModeSelector && mode && onModeChange ? (
<>
<DropdownMenuLabel className="typography-meta text-muted-foreground">
View mode
{t('diffView.selector.viewMode')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={mode}
@@ -248,7 +252,7 @@ const FileSelector = React.memo<FileSelectorProps>(({
className="items-center"
>
<span className="typography-meta text-foreground">
{option.label}
{t(option.labelKey)}
</span>
</DropdownMenuRadioItem>
))}
@@ -282,6 +286,7 @@ interface DiffViewModeSelectorProps {
}
const DiffViewModeSelector = React.memo<DiffViewModeSelectorProps>(({ mode, onModeChange }) => {
const { t } = useI18n();
const currentOption =
DIFF_VIEW_MODE_OPTIONS.find((option) => option.value === mode) ?? DIFF_VIEW_MODE_OPTIONS[0];
@@ -290,7 +295,7 @@ const DiffViewModeSelector = React.memo<DiffViewModeSelectorProps>(({ mode, onMo
<DropdownMenuTrigger asChild>
<button className="flex h-7 items-center gap-2 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground outline-none hover:bg-interactive-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
<span className="min-w-0 truncate typography-meta">
{currentOption.label}
{t(currentOption.labelKey)}
</span>
<RiArrowDownSLine className="size-4 opacity-50" />
</button>
@@ -303,7 +308,7 @@ const DiffViewModeSelector = React.memo<DiffViewModeSelectorProps>(({ mode, onMo
{DIFF_VIEW_MODE_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<span className="typography-meta text-foreground">
{option.label}
{t(option.labelKey)}
</span>
</DropdownMenuRadioItem>
))}
@@ -324,6 +329,7 @@ const FileList = React.memo<FileListProps>(({
selectedFile,
onSelectFile,
}) => {
const { t } = useI18n();
if (changedFiles.length === 0) return null;
return (
@@ -349,8 +355,8 @@ const FileList = React.memo<FileListProps>(({
<span
className="typography-micro font-semibold w-4 text-center uppercase"
style={{ color: descriptor.color }}
title={descriptor.description}
aria-label={descriptor.description}
title={t(descriptor.descriptionKey)}
aria-label={t(descriptor.descriptionKey)}
>
{descriptor.code}
</span>
@@ -385,6 +391,7 @@ const ImageDiffViewer = React.memo<ImageDiffViewerProps>(({
isVisible,
renderSideBySide,
}) => {
const { t } = useI18n();
const hasOriginal = diff.original.length > 0;
const hasModified = diff.modified.length > 0;
@@ -406,10 +413,10 @@ const ImageDiffViewer = React.memo<ImageDiffViewerProps>(({
<div className={containerClass}>
{hasOriginal && (
<div className={imageContainerClass}>
<span className="typography-meta text-muted-foreground font-medium">Original</span>
<span className="typography-meta text-muted-foreground font-medium">{t('diffView.image.original')}</span>
<img
src={diff.original}
alt={`Original: ${filePath}`}
alt={t('diffView.image.originalAlt', { path: filePath })}
className={renderSideBySide ? "max-w-full max-h-[calc(100%-2rem)] object-contain" : "max-w-full object-contain"}
style={{ imageRendering: 'auto' }}
/>
@@ -418,11 +425,11 @@ const ImageDiffViewer = React.memo<ImageDiffViewerProps>(({
{hasModified && (
<div className={imageContainerClass}>
<span className="typography-meta text-muted-foreground font-medium">
{hasOriginal ? 'Modified' : 'New'}
{hasOriginal ? t('diffView.image.modified') : t('diffView.image.new')}
</span>
<img
src={diff.modified}
alt={`Modified: ${filePath}`}
alt={t('diffView.image.modifiedAlt', { path: filePath })}
className={renderSideBySide ? "max-w-full max-h-[calc(100%-2rem)] object-contain" : "max-w-full object-contain"}
style={{ imageRendering: 'auto' }}
/>
@@ -444,6 +451,7 @@ const InlineImageDiffViewer = React.memo<InlineImageDiffViewerProps>(({
diff,
renderSideBySide,
}) => {
const { t } = useI18n();
const hasOriginal = diff.original.length > 0;
const hasModified = diff.modified.length > 0;
@@ -460,10 +468,10 @@ const InlineImageDiffViewer = React.memo<InlineImageDiffViewerProps>(({
<div className={containerClass}>
{hasOriginal && (
<div className={imageContainerClass}>
<span className="typography-meta text-muted-foreground font-medium">Original</span>
<span className="typography-meta text-muted-foreground font-medium">{t('diffView.image.original')}</span>
<img
src={diff.original}
alt={`Original: ${filePath}`}
alt={t('diffView.image.originalAlt', { path: filePath })}
className={renderSideBySide ? "max-w-full max-h-[70vh] object-contain" : "max-w-full object-contain"}
style={{ imageRendering: 'auto' }}
/>
@@ -472,11 +480,11 @@ const InlineImageDiffViewer = React.memo<InlineImageDiffViewerProps>(({
{hasModified && (
<div className={imageContainerClass}>
<span className="typography-meta text-muted-foreground font-medium">
{hasOriginal ? 'Modified' : 'New'}
{hasOriginal ? t('diffView.image.modified') : t('diffView.image.new')}
</span>
<img
src={diff.modified}
alt={`Modified: ${filePath}`}
alt={t('diffView.image.modifiedAlt', { path: filePath })}
className={renderSideBySide ? "max-w-full max-h-[70vh] object-contain" : "max-w-full object-contain"}
style={{ imageRendering: 'auto' }}
/>
@@ -632,6 +640,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
isOpeningInEditor = false,
onOpenInEditor,
}) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
const cachedDiff = useGitStore(
React.useCallback((state) => {
@@ -787,8 +796,8 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
<span
className="typography-micro font-semibold leading-none w-4 text-center uppercase"
style={{ color: descriptor.color }}
title={descriptor.description}
aria-label={descriptor.description}
title={t(descriptor.descriptionKey)}
aria-label={t(descriptor.descriptionKey)}
>
{descriptor.code}
</span>
@@ -839,7 +848,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-70 hover:opacity-100"
title="Open this file in editor at change"
title={t('diffView.actions.openFileInEditorAtChange')}
onClick={(event) => {
event.stopPropagation();
onOpenInEditor(file.path, diffData);
@@ -936,6 +945,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
pinSelectedFileHeaderToTopOnNavigate = false,
showOpenInEditorAction = false,
}) => {
const { t } = useI18n();
const { git, files } = useRuntimeAPIs();
const effectiveDirectory = useEffectiveDirectory();
const { screenWidth, isMobile } = useDeviceInfo();
@@ -1559,7 +1569,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
{showFileSidebar && (
<section className="hidden lg:flex w-72 flex-col rounded-xl border border-border/60 bg-background/70 overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border/40">
<span className="typography-ui-header font-semibold text-foreground">Files</span>
<span className="typography-ui-header font-semibold text-foreground">{t('diffView.section.files')}</span>
<span className="typography-meta text-muted-foreground">{changedFiles.length}</span>
</div>
<FileList
@@ -1612,7 +1622,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
if (!effectiveDirectory) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Select a session directory to view diffs
{t('diffView.state.selectSessionDirectory')}
</div>
);
}
@@ -1621,7 +1631,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
return (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
<RiLoader4Line size={16} className="animate-spin" />
Loading repository status
{t('diffView.state.loadingRepositoryStatus')}
</div>
);
}
@@ -1629,7 +1639,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
if (isGitRepo === false) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Not a git repository. Use the Git tab to initialize or change directories.
{t('diffView.state.notGitRepository')}
</div>
);
}
@@ -1637,7 +1647,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
if (changedFiles.length === 0) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Working tree clean no changes to display
{t('diffView.state.cleanWorkingTree')}
</div>
);
}
@@ -1654,7 +1664,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
{diffLoadError ? (
<div className="flex flex-col items-center gap-2">
<div className="typography-ui-label font-semibold text-foreground">
Failed to load diff
{t('diffView.state.failedToLoadDiff')}
</div>
<div className="typography-meta text-muted-foreground max-w-[32rem] text-center">
{diffLoadError}
@@ -1667,13 +1677,13 @@ export const DiffView: React.FC<DiffViewProps> = ({
setDiffRetryNonce((n) => n + 1);
}}
>
Retry
{t('diffView.actions.retry')}
</button>
</div>
) : (
<>
<RiLoader4Line size={16} className="animate-spin" />
Loading diff
{t('diffView.state.loadingDiff')}
</>
)}
</div>
@@ -1690,8 +1700,10 @@ export const DiffView: React.FC<DiffViewProps> = ({
<RiGitCommitLine size={16} />
<span className="typography-ui-label font-semibold text-foreground">
{isLoadingStatus && !status
? 'Loading changes'
: `${changedFiles.length} ${changedFiles.length === 1 ? 'file' : 'files'} changed`}
? t('diffView.state.loadingChanges')
: (changedFiles.length === 1
? t('diffView.summary.changedFilesSingle', { count: changedFiles.length })
: t('diffView.summary.changedFilesPlural', { count: changedFiles.length }))}
</span>
</div>
)}
@@ -1720,7 +1732,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
'h-5 w-5 p-0 transition-opacity',
diffWrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
)}
title={diffWrapLines ? 'Disable line wrap' : 'Enable line wrap'}
title={diffWrapLines ? t('diffView.actions.disableLineWrap') : t('diffView.actions.enableLineWrap')}
>
<RiTextWrap className="size-4" />
</Button>
@@ -1734,7 +1746,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
void openSelectedFileInEditorAtChange();
}}
disabled={isOpeningSelectedInEditor}
title="Open this file at first changed line"
title={t('diffView.actions.openFileAtFirstChangedLine')}
>
{isOpeningSelectedInEditor ? (
<RiLoader4Line className="size-3.5 animate-spin" />
+113 -110
View File
@@ -61,7 +61,7 @@ import {
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useDeviceInfo } from '@/lib/device';
import { cn, getModifierLabel, getRevealLabel, hasModifier } from '@/lib/utils';
import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils';
import { getLanguageFromExtension, getImageMimeType, isImageFile } from '@/lib/toolHelpers';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { EditorView } from '@codemirror/view';
@@ -84,6 +84,7 @@ import { getDefaultTheme } from '@/lib/theme/themes';
import { openDesktopFileInApp, openDesktopPath } from '@/lib/desktop';
import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore';
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { useI18n } from '@/lib/i18n';
type FileNode = {
name: string;
@@ -329,6 +330,7 @@ const FileRow: React.FC<FileRowProps> = ({
onRevealPath,
onOpenDialog,
}) => {
const { t } = useI18n();
const isDir = node.type === 'directory';
const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions;
@@ -413,45 +415,45 @@ const FileRow: React.FC<FileRowProps> = ({
<DropdownMenuContent align="end" side={isMobile ? "bottom" : "bottom"} onCloseAutoFocus={() => setContextMenuPath(null)}>
{canRename && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('rename', node); }}>
<RiEditLine className="mr-2 h-4 w-4" /> Rename
<RiEditLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.rename')}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
void copyTextToClipboard(node.path).then((result) => {
if (result.ok) {
toast.success('Path copied');
toast.success(t('sidebarFilesTree.toast.pathCopied'));
return;
}
toast.error('Copy failed');
toast.error(t('sidebarFilesTree.toast.copyFailed'));
});
}}>
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
<RiFileCopyLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.copyPath')}
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
const relativePath = getDisplayPath(root, node.path) || node.path;
void copyTextToClipboard(relativePath).then((result) => {
if (result.ok) {
toast.success('Relative path copied');
toast.success(t('filesView.toast.relativePathCopied'));
return;
}
toast.error('Copy failed');
toast.error(t('sidebarFilesTree.toast.copyFailed'));
});
}}>
<RiFileCopy2Line className="mr-2 h-4 w-4" /> Copy Relative Path
<RiFileCopy2Line className="mr-2 h-4 w-4" /> {t('filesView.tree.menu.copyRelativePath')}
</DropdownMenuItem>
{!isDir && downloadFile && (
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
void downloadFile(node.path);
}}>
<RiDownloadLine className="mr-2 h-4 w-4" /> Save
<RiDownloadLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.save')}
</DropdownMenuItem>
)}
{canReveal && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onRevealPath(node.path); }}>
<RiFolderReceivedLine className="mr-2 h-4 w-4" /> {getRevealLabel()}
<RiFolderReceivedLine className="mr-2 h-4 w-4" /> {t(getRevealLabelKey())}
</DropdownMenuItem>
)}
{isDir && (canCreateFile || canCreateFolder) && (
@@ -459,12 +461,12 @@ const FileRow: React.FC<FileRowProps> = ({
<DropdownMenuSeparator />
{canCreateFile && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('createFile', node); }}>
<RiFileAddLine className="mr-2 h-4 w-4" /> New File
<RiFileAddLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.newFile')}
</DropdownMenuItem>
)}
{canCreateFolder && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('createFolder', node); }}>
<RiFolderAddLine className="mr-2 h-4 w-4" /> New Folder
<RiFolderAddLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.newFolder')}
</DropdownMenuItem>
)}
</>
@@ -476,7 +478,7 @@ const FileRow: React.FC<FileRowProps> = ({
onClick={(e) => { e.stopPropagation(); onOpenDialog('delete', node); }}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="mr-2 h-4 w-4" /> Delete
<RiDeleteBinLine className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.delete')}
</DropdownMenuItem>
</>
)}
@@ -493,6 +495,7 @@ interface FilesViewProps {
}
export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const { t } = useI18n();
const { files, runtime } = useRuntimeAPIs();
const { currentTheme, availableThemes, lightThemeId, darkThemeId } = useThemeSystem();
const { isMobile, screenWidth } = useDeviceInfo();
@@ -681,9 +684,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const handleRevealPath = React.useCallback((targetPath: string) => {
if (!files.revealPath) return;
void files.revealPath(targetPath).catch(() => {
toast.error('Failed to reveal path');
toast.error(t('sidebarFilesTree.toast.revealFailed'));
});
}, [files]);
}, [files, t]);
const handleOpenInApp = React.useCallback(async (app: { id: string; appName: string }) => {
if (!selectedFile?.path) {
@@ -707,8 +710,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return;
}
}
toast.error(`Failed to open in ${app.appName}`);
}, [root, selectedFile?.path]);
toast.error(t('filesView.toast.openInAppFailed', { app: app.appName }));
}, [root, selectedFile?.path, t]);
const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => {
setActiveDialog(type);
@@ -1018,12 +1021,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
if (activeDialog === 'createFile') {
if (!dialogInputValue.trim()) {
failDialogOperation('Filename is required');
failDialogOperation(t('sidebarFilesTree.toast.filenameRequired'));
done();
return;
}
if (!files.writeFile) {
failDialogOperation('Write not supported');
failDialogOperation(t('sidebarFilesTree.toast.writeNotSupported'));
done();
return;
}
@@ -1034,19 +1037,19 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
await files.writeFile(newPath, '')
.then(async (result) => {
if (result.success) {
toast.success('File created');
toast.success(t('sidebarFilesTree.toast.fileCreated'));
await refreshDirectory(parentPath);
}
finishDialogOperation();
})
.catch(() => failDialogOperation('Operation failed'))
.catch(() => failDialogOperation(t('sidebarFilesTree.toast.operationFailed')))
.finally(done);
return;
}
if (activeDialog === 'createFolder') {
if (!dialogInputValue.trim()) {
failDialogOperation('Folder name is required');
failDialogOperation(t('sidebarFilesTree.toast.folderNameRequired'));
done();
return;
}
@@ -1057,25 +1060,25 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
await files.createDirectory(newPath)
.then(async (result) => {
if (result.success) {
toast.success('Folder created');
toast.success(t('sidebarFilesTree.toast.folderCreated'));
await refreshDirectory(parentPath);
}
finishDialogOperation();
})
.catch(() => failDialogOperation('Operation failed'))
.catch(() => failDialogOperation(t('sidebarFilesTree.toast.operationFailed')))
.finally(done);
return;
}
if (activeDialog === 'rename') {
if (!dialogInputValue.trim()) {
failDialogOperation('Name is required');
failDialogOperation(t('sidebarFilesTree.toast.nameRequired'));
done();
return;
}
if (!files.rename) {
failDialogOperation('Rename not supported');
failDialogOperation(t('sidebarFilesTree.toast.renameNotSupported'));
done();
return;
}
@@ -1088,7 +1091,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
await files.rename(oldPath, newPath)
.then(async (result) => {
if (result.success) {
toast.success('Renamed successfully');
toast.success(t('sidebarFilesTree.toast.renamedSuccessfully'));
await refreshDirectory(parentDir);
if (root) {
removeOpenPathsByPrefix(root, oldPath);
@@ -1108,14 +1111,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}
finishDialogOperation();
})
.catch(() => failDialogOperation('Operation failed'))
.catch(() => failDialogOperation(t('sidebarFilesTree.toast.operationFailed')))
.finally(done);
return;
}
if (activeDialog === 'delete') {
if (!files.delete) {
failDialogOperation('Delete not supported');
failDialogOperation(t('sidebarFilesTree.toast.deleteNotSupported'));
done();
return;
}
@@ -1125,7 +1128,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
await files.delete(deletedPath)
.then(async (result) => {
if (result.success) {
toast.success('Deleted successfully');
toast.success(t('sidebarFilesTree.toast.deletedSuccessfully'));
await refreshDirectory(parentDir);
if (root) {
removeOpenPathsByPrefix(root, deletedPath);
@@ -1145,13 +1148,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}
finishDialogOperation();
})
.catch(() => failDialogOperation('Operation failed'))
.catch(() => failDialogOperation(t('sidebarFilesTree.toast.operationFailed')))
.finally(done);
return;
}
done();
}, [activeDialog, dialogData, dialogInputValue, files, refreshDirectory, isMobile, removeOpenPathsByPrefix, root, selectedFile?.path, setSelectedPath]);
}, [activeDialog, dialogData, dialogInputValue, files, refreshDirectory, isMobile, removeOpenPathsByPrefix, root, selectedFile?.path, setSelectedPath, t]);
React.useEffect(() => {
if (!currentDirectory) {
@@ -1217,10 +1220,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error((error as { error?: string }).error || 'Failed to read file');
throw new Error((error as { error?: string }).error || t('filesView.error.readFileFailed'));
}
return response.text();
}, [files]);
}, [files, t]);
const readFileStat = React.useCallback(async (path: string): Promise<FileStatSnapshot | null> => {
if (files.statFile) {
@@ -1244,7 +1247,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const saveDraft = React.useCallback(async () => {
if (!selectedFile || !files.writeFile) {
toast.error('Saving not supported');
toast.error(t('filesView.toast.savingNotSupported'));
return;
}
@@ -1257,7 +1260,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
await files.writeFile(selectedFile.path, draftContent)
.then((result) => {
if (!result?.success) {
toast.error('Failed to write file');
toast.error(t('filesView.toast.writeFileFailed'));
return;
}
setFileContent(draftContent);
@@ -1271,12 +1274,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
.catch(() => {});
})
.catch((error) => {
toast.error(error instanceof Error ? error.message : 'Save failed');
toast.error(error instanceof Error ? error.message : t('filesView.toast.saveFailed'));
})
.finally(() => {
setIsSaving(false);
});
}, [draftContent, files, isDirty, readFileStat, selectedFile]);
}, [draftContent, files, isDirty, readFileStat, selectedFile, t]);
React.useEffect(() => {
if (!isDirty) {
@@ -1440,13 +1443,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}
setFileContent('');
setDraftContent('');
setFileError(error instanceof Error ? error.message : 'Failed to read file');
setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
lastLoadedFileStatRef.current = null;
})
.finally(() => {
setFileLoading(false);
});
}, [expandPaths, isMobile, loadDirectory, readFile, readFileStat, root, runtime.isDesktop, searchQuery, setSelectedPath]);
}, [expandPaths, isMobile, loadDirectory, readFile, readFileStat, root, runtime.isDesktop, searchQuery, setSelectedPath, t]);
const ensurePathVisible = React.useCallback(async (targetPath: string, includeTarget: boolean) => {
if (!root) {
@@ -2305,7 +2308,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
.catch((error) => {
if (!cancelled) {
setDesktopImageSrc('');
setFileError(error instanceof Error ? error.message : 'Failed to read file');
setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
setLoadedFilePath(null);
}
})
@@ -2328,16 +2331,16 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<DialogContent>
<DialogHeader>
<DialogTitle>
{activeDialog === 'createFile' && 'Create File'}
{activeDialog === 'createFolder' && 'Create Folder'}
{activeDialog === 'rename' && 'Rename'}
{activeDialog === 'delete' && 'Delete'}
{activeDialog === 'createFile' && t('filesView.dialog.createFile.title')}
{activeDialog === 'createFolder' && t('filesView.dialog.createFolder.title')}
{activeDialog === 'rename' && t('filesView.dialog.rename.title')}
{activeDialog === 'delete' && t('filesView.dialog.delete.title')}
</DialogTitle>
<DialogDescription>
{activeDialog === 'createFile' && `Create a new file in ${dialogData?.path ?? 'root'}`}
{activeDialog === 'createFolder' && `Create a new folder in ${dialogData?.path ?? 'root'}`}
{activeDialog === 'rename' && `Rename ${dialogData?.name}`}
{activeDialog === 'delete' && `Are you sure you want to delete ${dialogData?.name}? This action cannot be undone.`}
{activeDialog === 'createFile' && t('filesView.dialog.createFile.description', { path: dialogData?.path ?? t('filesView.dialog.rootFallback') })}
{activeDialog === 'createFolder' && t('filesView.dialog.createFolder.description', { path: dialogData?.path ?? t('filesView.dialog.rootFallback') })}
{activeDialog === 'rename' && t('filesView.dialog.rename.description', { name: dialogData?.name ?? '' })}
{activeDialog === 'delete' && t('filesView.dialog.delete.description', { name: dialogData?.name ?? '' })}
</DialogDescription>
</DialogHeader>
@@ -2346,7 +2349,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<Input
value={dialogInputValue}
onChange={(e) => setDialogInputValue(e.target.value)}
placeholder={activeDialog === 'rename' ? 'New name' : 'Name'}
placeholder={activeDialog === 'rename' ? t('filesView.dialog.rename.placeholder') : t('filesView.dialog.namePlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
void handleDialogSubmit();
@@ -2359,7 +2362,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<DialogFooter>
<Button variant="outline" onClick={() => setActiveDialog(null)} disabled={isDialogSubmitting}>
Cancel
{t('filesView.dialog.cancel')}
</Button>
<Button
variant={activeDialog === 'delete' ? 'destructive' : 'default'}
@@ -2367,7 +2370,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
disabled={isDialogSubmitting || (activeDialog !== 'delete' && !dialogInputValue.trim())}
>
{isDialogSubmitting ? <RiLoader4Line className="animate-spin" /> : (
activeDialog === 'delete' ? 'Delete' : 'Confirm'
activeDialog === 'delete' ? t('filesView.dialog.delete.confirm') : t('filesView.dialog.confirm')
)}
</Button>
</DialogFooter>
@@ -2431,12 +2434,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
isSaving ? (
<span className="flex items-center gap-1 px-1 text-muted-foreground typography-meta">
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
Saving...
{t('filesView.editor.saving')}
</span>
) : autoSaveStatus === 'saved' && !isDirty ? (
<span className="flex items-center gap-1 px-1 text-[color:var(--status-success)] typography-meta">
<RiCheckLine className="h-3.5 w-3.5" />
Saved
{t('filesView.editor.saved')}
</span>
) : isDirty ? (
<Button
@@ -2444,8 +2447,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
size="sm"
onClick={() => void saveDraft()}
className="h-6 gap-1 px-1 text-muted-foreground opacity-80 hover:bg-transparent hover:opacity-100 focus-visible:bg-transparent active:bg-transparent"
title={`Save now (${getModifierLabel()}+S) - auto-saves after 1.5s`}
aria-label={`Save (${getModifierLabel()}+S)`}
title={t('filesView.editor.saveNowTitle', { shortcut: `${getModifierLabel()}+S` })}
aria-label={t('filesView.editor.saveAria', { shortcut: `${getModifierLabel()}+S` })}
>
<RiSave3Line className="h-4 w-4" />
</Button>
@@ -2458,8 +2461,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-foreground opacity-100 hover:bg-transparent focus-visible:bg-transparent active:bg-transparent"
title="Open in desktop app"
aria-label="Open in desktop app"
title={t('filesView.editor.openInDesktopApp')}
aria-label={t('filesView.editor.openInDesktopApp')}
>
<RiFileTransferLine className="h-4 w-4" />
</Button>
@@ -2481,7 +2484,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
onClick={() => void loadOpenInApps(true)}
>
<RiRefreshLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Refresh Apps</span>
<span className="typography-ui-label text-foreground">{t('filesView.editor.refreshApps')}</span>
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
@@ -2497,7 +2500,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
'h-6 w-6 p-0 transition-opacity hover:bg-transparent focus-visible:bg-transparent active:bg-transparent',
wrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-65 hover:opacity-100'
)}
title={wrapLines ? 'Disable line wrap' : 'Enable line wrap'}
title={wrapLines ? t('filesView.editor.disableLineWrap') : t('filesView.editor.enableLineWrap')}
>
<RiTextWrap className="size-4" />
</Button>
@@ -2511,7 +2514,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
event.currentTarget.blur();
}}
className="h-6 w-6 p-0 text-foreground opacity-100 transition-opacity hover:bg-transparent focus-visible:bg-transparent active:bg-transparent"
title="Find in file"
title={t('filesView.editor.findInFile')}
>
<RiSearchLine className="size-4" />
</Button>
@@ -2523,7 +2526,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
event.currentTarget.blur();
}}
className="h-6 w-6 p-0 text-foreground opacity-100 transition-opacity hover:bg-transparent focus-visible:bg-transparent active:bg-transparent"
title="Go to line"
title={t('filesView.editor.goToLine')}
>
<RiMenuFold2Line className="size-4" />
</Button>
@@ -2557,7 +2560,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
size="sm"
onClick={() => saveJsonViewMode(jsonViewMode === 'tree' ? 'text' : 'tree')}
className="h-6 w-6 p-0 text-muted-foreground opacity-65 hover:bg-transparent hover:opacity-100 focus-visible:bg-transparent active:bg-transparent"
title={jsonViewMode === 'tree' ? 'Switch to Text View' : 'Switch to Tree View'}
title={jsonViewMode === 'tree' ? t('filesView.editor.switchToTextView') : t('filesView.editor.switchToTreeView')}
>
{jsonViewMode === 'tree' ? (
<RiCodeSSlashLine className="size-4" />
@@ -2582,12 +2585,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
setCopiedContent(false);
}, 1200);
} else {
toast.error('Copy failed');
toast.error(t('filesView.toast.copyFailed'));
}
}}
className="h-6 w-6 p-0 hover:bg-transparent focus-visible:bg-transparent active:bg-transparent"
title="Copy file contents"
aria-label="Copy file contents"
title={t('filesView.editor.copyFileContents')}
aria-label={t('filesView.editor.copyFileContents')}
>
{copiedContent ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
@@ -2612,12 +2615,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
setCopiedPath(false);
}, 1200);
} else {
toast.error('Copy failed');
toast.error(t('filesView.toast.copyFailed'));
}
}}
className="h-6 w-6 p-0 hover:bg-transparent focus-visible:bg-transparent active:bg-transparent"
title={`Copy file path (${displaySelectedPath})`}
aria-label={`Copy file path (${displaySelectedPath})`}
title={t('filesView.editor.copyFilePathTitle', { path: displaySelectedPath })}
aria-label={t('filesView.editor.copyFilePathTitle', { path: displaySelectedPath })}
>
{copiedPath ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
@@ -2636,8 +2639,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
if (fn) void fn(selectedFile.path);
}}
className="h-6 w-6 p-0 hover:bg-transparent focus-visible:bg-transparent active:bg-transparent"
title="Save file"
aria-label="Save file"
title={t('filesView.editor.saveFile')}
aria-label={t('filesView.editor.saveFile')}
>
<RiDownloadLine className="h-4 w-4" />
</Button>
@@ -2649,8 +2652,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
size="sm"
onClick={() => setIsFullscreen(false)}
className="h-6 w-6 p-0 hover:bg-transparent focus-visible:bg-transparent active:bg-transparent"
title="Exit fullscreen"
aria-label="Exit fullscreen"
title={t('filesView.editor.exitFullscreen')}
aria-label={t('filesView.editor.exitFullscreen')}
>
<RiFullscreenExitLine className="h-4 w-4" />
</Button>
@@ -2660,8 +2663,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
size="sm"
onClick={() => setIsFullscreen(!isFullscreen)}
className="h-6 w-6 p-0 hover:bg-transparent focus-visible:bg-transparent active:bg-transparent"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
title={isFullscreen ? t('filesView.editor.exitFullscreen') : t('filesView.editor.fullscreen')}
aria-label={isFullscreen ? t('filesView.editor.exitFullscreen') : t('filesView.editor.fullscreen')}
>
{isFullscreen ? (
<RiFullscreenExitLine className="h-4 w-4" />
@@ -2686,9 +2689,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}}>
<DialogContent showCloseButton={false} className="max-w-md">
<DialogHeader>
<DialogTitle>Unsaved changes</DialogTitle>
<DialogTitle>{t('filesView.unsaved.title')}</DialogTitle>
<DialogDescription>
Save your edits before continuing?
{t('filesView.unsaved.description')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -2698,9 +2701,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
disabled={isSaving}
className="border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)] hover:bg-[rgb(var(--status-success)/0.2)]"
>
Save changes
{t('filesView.unsaved.saveChanges')}
</Button>
<Button variant="destructive" onClick={discardAndContinue}>Discard</Button>
<Button variant="destructive" onClick={discardAndContinue}>{t('filesView.unsaved.discard')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -2712,7 +2715,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<button
type="button"
onClick={() => setShowMobilePageContent(false)}
aria-label="Back"
aria-label={t('filesView.editor.back')}
className="inline-flex h-7 w-7 flex-shrink-0 items-center justify-center mr-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<RiArrowLeftSLine className="h-5 w-5" />
@@ -2726,7 +2729,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<button
type="button"
className="inline-flex min-w-0 max-w-full items-center gap-1 text-left typography-ui-label font-medium"
aria-label="Open files"
aria-label={t('filesView.editor.openFilesAria')}
>
<FileTypeIcon filePath={selectedFile.path} extension={selectedFile.extension} className="h-3.5 w-3.5 flex-shrink-0" />
<span className="min-w-0 flex-1 truncate">{selectedFile.name}</span>
@@ -2771,7 +2774,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
handleCloseFile(file.path);
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]"
aria-label={`Close ${file.name}`}
aria-label={t('filesView.editor.closeFileAria', { name: file.name })}
>
<RiCloseLine className="h-3.5 w-3.5" />
</button>
@@ -2781,7 +2784,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="typography-ui-label font-medium truncate">Select a file</div>
<div className="typography-ui-label font-medium truncate">{t('filesView.editor.selectFile')}</div>
)
) : (
openFiles.length > 0 ? (
@@ -2832,7 +2835,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
'rounded-sm p-0.5 text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
!isActive && 'opacity-0 group-hover:opacity-100'
)}
aria-label={`Close ${file.name}`}
aria-label={t('filesView.editor.closeFileAria', { name: file.name })}
>
<RiCloseLine size={14} />
</button>
@@ -2842,7 +2845,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</div>
</div>
) : (
<div className="typography-ui-label font-medium truncate">Select a file</div>
<div className="typography-ui-label font-medium truncate">{t('filesView.editor.selectFile')}</div>
)
)}
</div>
@@ -2869,8 +2872,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
size="sm"
onClick={() => setIsFloatingToolbarOpen(true)}
className="h-8 w-8 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-0 text-muted-foreground shadow-sm hover:text-foreground"
aria-label="Show editor controls"
title="Editor controls"
aria-label={t('filesView.editor.showControlsAria')}
title={t('filesView.editor.controlsTitle')}
>
<RiMore2Fill className="h-4 w-4" />
</Button>
@@ -2879,14 +2882,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
)}
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{!selectedFile ? (
<div className="p-3 typography-ui text-muted-foreground">Pick a file from the tree.</div>
<div className="p-3 typography-ui text-muted-foreground">{t('filesView.editor.pickFileFromTree')}</div>
) : fileLoading ? (
suppressFileLoadingIndicator
? <div className="p-3" />
: (
<div className="p-3 flex items-center gap-2 typography-ui text-muted-foreground">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading
{t('filesView.state.loading')}
</div>
)
) : fileError ? (
@@ -2895,7 +2898,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<div className="flex h-full items-center justify-center p-3">
<img
src={imageSrc}
alt={selectedFile?.name ?? 'Image'}
alt={selectedFile?.name ?? t('filesView.editor.imageAltFallback')}
className="max-w-full max-h-[70vh] object-contain rounded-md border border-border/30 bg-primary/10"
/>
</div>
@@ -2903,9 +2906,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<ErrorBoundary
fallback={
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
<div className="mb-1 font-medium text-destructive">JSON viewer unavailable</div>
<div className="mb-1 font-medium text-destructive">{t('filesView.error.jsonViewerUnavailable')}</div>
<div className="text-sm text-muted-foreground">
Switch to text mode to view raw content.
{t('filesView.error.switchToTextMode')}
</div>
</div>
}
@@ -2922,15 +2925,15 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<div className="h-full overflow-auto p-3">
{fileContent.length > 500 * 1024 && (
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
This file is large ({Math.round(fileContent.length / 1024)}KB). Preview may be limited.
{t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })}
</div>
)}
<ErrorBoundary
fallback={
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
<div className="mb-1 font-medium text-destructive">Preview unavailable</div>
<div className="mb-1 font-medium text-destructive">{t('filesView.error.previewUnavailable')}</div>
<div className="text-sm text-muted-foreground">
Switch to edit mode to fix the issue.
{t('filesView.error.switchToEditMode')}
</div>
</div>
}
@@ -2954,7 +2957,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
})()}
className="w-full h-full border-none"
sandbox="allow-scripts allow-same-origin allow-forms"
title="HTML Preview"
title={t('filesView.editor.htmlPreviewTitle')}
/>
</div>
) : selectedFile && canUseShikiFileView && textViewMode === 'view' ? (
@@ -3063,7 +3066,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background">
<div className="flex items-center gap-2 typography-ui text-muted-foreground">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Opening file at change...
{t('filesView.state.openingFileAtChange')}
</div>
</div>
)}
@@ -3089,13 +3092,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
ref={searchInputRef}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search files…"
placeholder={t('filesView.tree.search.placeholder')}
className="h-8 pl-8 pr-8 typography-meta"
/>
{searchQuery.trim().length > 0 && (
<button
type="button"
aria-label="Clear search"
aria-label={t('filesView.tree.search.clearAria')}
className="absolute right-2 top-2 inline-flex h-4 w-4 items-center justify-center text-muted-foreground hover:text-foreground"
onClick={() => {
setSearchQuery('');
@@ -3111,7 +3114,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
size="sm"
onClick={() => handleOpenDialog('createFile', { path: currentDirectory, type: 'directory' })}
className="h-8 w-8 p-0 flex-shrink-0"
title="New File"
title={t('filesView.tree.actions.newFileTitle')}
>
<RiFileAddLine className="h-4 w-4" />
</Button>
@@ -3120,7 +3123,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
size="sm"
onClick={() => handleOpenDialog('createFolder', { path: currentDirectory, type: 'directory' })}
className="h-8 w-8 p-0 flex-shrink-0"
title="New Folder"
title={t('filesView.tree.actions.newFolderTitle')}
>
<RiFolderAddLine className="h-4 w-4" />
</Button>
@@ -3135,7 +3138,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
{searching ? (
<li className="flex items-center gap-1.5 px-2 py-1 typography-meta text-muted-foreground">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Searching
{t('filesView.tree.search.searching')}
</li>
) : searchResults.length > 0 ? (
searchResults.map((node) => {
@@ -3165,7 +3168,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
) : hasTree ? (
renderTree(root, 0)
) : (
<li className="px-2 py-1 typography-meta text-muted-foreground">Loading</li>
<li className="px-2 py-1 typography-meta text-muted-foreground">{t('filesView.state.loading')}</li>
)}
</ul>
</ScrollableOverlay>
@@ -3203,16 +3206,16 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
) : isMarkdown && getMdViewMode() === 'preview' ? (
<div className="h-full overflow-auto p-4">
{fileContent.length > 500 * 1024 && (
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
This file is large ({Math.round(fileContent.length / 1024)}KB). Preview may be limited.
</div>
)}
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
{t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })}
</div>
)}
<ErrorBoundary
fallback={
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
<div className="mb-1 font-medium text-destructive">Preview unavailable</div>
<div className="mb-1 font-medium text-destructive">{t('filesView.error.previewUnavailable')}</div>
<div className="text-sm text-muted-foreground">
Switch to edit mode to fix the issue.
{t('filesView.error.switchToEditMode')}
</div>
</div>
}
@@ -3251,7 +3254,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background">
<div className="flex items-center gap-2 typography-ui text-muted-foreground">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Opening file at change...
{t('filesView.state.openingFileAtChange')}
</div>
</div>
)}
+86 -74
View File
@@ -67,6 +67,7 @@ import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { cn } from '@/lib/utils';
import { generateCommitMessage as generateSessionCommitMessage, getGitWorktreeBootstrapStatus } from '@/lib/gitApi';
import { sessionEvents } from '@/lib/sessionEvents';
import { useI18n } from '@/lib/i18n';
type SyncAction = 'fetch' | 'pull' | 'push' | null;
type CommitAction = 'commit' | 'commitAndPush' | null;
@@ -227,6 +228,7 @@ const normalizePath = (value?: string | null): string =>
(value || '').replace(/\\/g, '/').replace(/\/+$/, '');
export const GitView: React.FC = () => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory();
const [worktreeBootstrapStatus, setWorktreeBootstrapStatus] = React.useState<'pending' | 'ready' | 'failed' | null>(null);
@@ -511,11 +513,11 @@ export const GitView: React.FC = () => {
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = React.useState(false);
const actionTabItems = React.useMemo(() => [
{ id: 'commit', label: 'Commit', icon: <RiGitCommitLine className="h-3.5 w-3.5" /> },
{ id: 'branch', label: 'Update', icon: <RiGitMergeLine className="h-3.5 w-3.5" /> },
{ id: 'pr', label: 'PR', icon: <RiGitPullRequestLine className="h-3.5 w-3.5" /> },
{ id: 'worktree', label: 'Worktree', icon: <RiSplitCellsHorizontal className="h-3.5 w-3.5" /> },
], []);
{ id: 'commit', label: t('gitView.tabs.commit'), icon: <RiGitCommitLine className="h-3.5 w-3.5" /> },
{ id: 'branch', label: t('gitView.tabs.update'), icon: <RiGitMergeLine className="h-3.5 w-3.5" /> },
{ id: 'pr', label: t('gitView.tabs.pr'), icon: <RiGitPullRequestLine className="h-3.5 w-3.5" /> },
{ id: 'worktree', label: t('gitView.tabs.worktree'), icon: <RiSplitCellsHorizontal className="h-3.5 w-3.5" /> },
], [t]);
const [actionTab, setActionTab] = React.useState<ActionTab>(() => {
if (typeof window === 'undefined') {
return 'commit';
@@ -596,12 +598,12 @@ export const GitView: React.FC = () => {
const handleCopyCommitHash = React.useCallback((hash: string) => {
void copyTextToClipboard(hash).then((result) => {
if (result.ok) {
toast.success('Commit hash copied');
toast.success(t('gitView.toast.commitHashCopied'));
return;
}
toast.error('Failed to copy');
toast.error(t('gitView.toast.copyFailed'));
});
}, []);
}, [t]);
const handleToggleCommit = React.useCallback((hash: string) => {
setExpandedCommitHashes((prev) => {
@@ -770,7 +772,7 @@ export const GitView: React.FC = () => {
} catch (err) {
if (showErrors) {
const message =
err instanceof Error ? err.message : 'Failed to refresh repository state';
err instanceof Error ? err.message : t('gitView.toast.refreshRepositoryFailed');
toast.error(message);
}
}
@@ -906,18 +908,20 @@ export const GitView: React.FC = () => {
throw new Error('No remote available for fetch');
}
await git.gitFetch(currentDirectory, { remote: remote.name });
toast.success(`Fetched from ${remote.name}`);
toast.success(t('gitView.toast.fetchedFromRemote', { name: remote.name }));
} else if (action === 'pull') {
if (!remote) {
throw new Error('No remote available for pull');
}
const result = await git.gitPull(currentDirectory, { remote: remote.name });
toast.success(
`Pulled ${result.files.length} file${result.files.length === 1 ? '' : 's'} from ${remote.name}`
result.files.length === 1
? t('gitView.toast.pulledFilesSingle', { count: result.files.length, name: remote.name })
: t('gitView.toast.pulledFilesPlural', { count: result.files.length, name: remote.name })
);
} else if (action === 'push') {
await git.gitPush(currentDirectory);
toast.success('Pushed to upstream');
toast.success(t('gitView.toast.pushedToUpstream'));
}
await refreshStatusAndBranches(false);
@@ -926,7 +930,7 @@ export const GitView: React.FC = () => {
const message =
err instanceof Error
? err.message
: `Failed to ${action === 'pull' ? 'pull' : action}`;
: t('gitView.toast.syncActionFailed', { action: action === 'pull' ? t('gitView.sync.pull') : action });
toast.error(message);
} finally {
setSyncAction(null);
@@ -938,18 +942,18 @@ export const GitView: React.FC = () => {
const remoteName = remote.name.trim();
if (!remoteName) {
toast.error('Remote name is required');
toast.error(t('gitView.toast.remoteNameRequired'));
return;
}
if (remoteName === 'origin') {
toast.error('Cannot remove origin remote');
toast.error(t('gitView.toast.cannotRemoveOriginRemote'));
return;
}
setRemovingRemoteName(remoteName);
try {
await git.removeRemote(currentDirectory, { remote: remoteName });
toast.success(`Removed ${remoteName} remote`);
toast.success(t('gitView.toast.removedRemote', { name: remoteName }));
await Promise.all([
refreshStatusAndBranches(false),
refreshRemotes(),
@@ -965,13 +969,13 @@ export const GitView: React.FC = () => {
const handleCommit = async (options: { pushAfter?: boolean } = {}) => {
if (!currentDirectory) return;
if (!commitMessage.trim()) {
toast.error('Please enter a commit message');
toast.error(t('gitView.toast.enterCommitMessage'));
return;
}
const filesToCommit = Array.from(selectedPaths).sort();
if (filesToCommit.length === 0) {
toast.error('Select at least one file to commit');
toast.error(t('gitView.toast.selectFileToCommit'));
return;
}
@@ -982,7 +986,7 @@ export const GitView: React.FC = () => {
await git.createGitCommit(currentDirectory, commitMessage.trim(), {
files: filesToCommit,
});
toast.success('Commit created successfully');
toast.success(t('gitView.toast.commitCreated'));
setCommitMessage('');
setSelectedPaths(new Set());
setHasUserAdjustedSelection(false);
@@ -992,7 +996,7 @@ export const GitView: React.FC = () => {
if (options.pushAfter) {
await git.gitPush(currentDirectory);
toast.success('Pushed to upstream');
toast.success(t('gitView.toast.pushedToUpstream'));
triggerFireworks();
await refreshStatusAndBranches(false);
} else {
@@ -1002,7 +1006,7 @@ export const GitView: React.FC = () => {
await refreshLog();
setIntegrateRefreshKey((v) => v + 1);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create commit';
const message = err instanceof Error ? err.message : t('gitView.toast.createCommitFailed');
toast.error(message);
} finally {
setCommitAction(null);
@@ -1012,7 +1016,7 @@ export const GitView: React.FC = () => {
const handleGenerateCommitMessage = React.useCallback(async () => {
if (!currentDirectory) return;
if (selectedPaths.size === 0) {
toast.error('Select at least one file to describe');
toast.error(t('gitView.toast.selectFileToDescribe'));
return;
}
@@ -1049,7 +1053,7 @@ export const GitView: React.FC = () => {
error,
});
const message =
error instanceof Error ? error.message : 'Failed to generate commit message';
error instanceof Error ? error.message : t('gitView.toast.generateCommitMessageFailed');
toast.error(message);
} finally {
setIsGeneratingMessage(false);
@@ -1071,7 +1075,7 @@ export const GitView: React.FC = () => {
const blockingReasons = getMutationBlockingReasons(worktreeAttachment);
if (blockingReasons.length > 0) {
toast.error(`Cannot create branch: ${formatBlockingReason(blockingReasons[0])}`);
toast.error(t('gitView.toast.cannotCreateBranch', { reason: formatBlockingReason(blockingReasons[0]) }));
return;
}
@@ -1080,7 +1084,7 @@ export const GitView: React.FC = () => {
try {
await git.createBranch(currentDirectory, branchName, checkoutBase ?? 'HEAD');
toast.success(`Created branch ${branchName}`);
toast.success(t('gitView.toast.createdBranch', { name: branchName }));
// Checkout the new branch and stay on it
await git.checkoutBranch(currentDirectory, branchName);
@@ -1098,7 +1102,7 @@ export const GitView: React.FC = () => {
pushError instanceof Error
? pushError.message
: `Unable to push new branch to ${remoteName}.`;
toast.warning('Branch created locally', {
toast.warning(t('gitView.toast.branchCreatedLocally'), {
description: (
<span className="text-foreground/80 dark:text-foreground/70">
Upstream setup failed: {message}
@@ -1111,10 +1115,10 @@ export const GitView: React.FC = () => {
await refreshLog();
if (pushSucceeded) {
toast.success(`Upstream set for ${branchName} on ${remoteName}`);
toast.success(t('gitView.toast.upstreamSet', { branch: branchName, remote: remoteName }));
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create branch';
const message = err instanceof Error ? err.message : t('gitView.toast.createBranchFailed');
toast.error(message);
throw err;
}
@@ -1125,18 +1129,18 @@ export const GitView: React.FC = () => {
const blockingReasons = getMutationBlockingReasons(worktreeAttachment);
if (blockingReasons.length > 0) {
toast.error(`Cannot rename branch: ${formatBlockingReason(blockingReasons[0])}`);
toast.error(t('gitView.toast.cannotRenameBranch', { reason: formatBlockingReason(blockingReasons[0]) }));
return;
}
try {
await git.renameBranch(currentDirectory, oldName, newName);
toast.success(`Renamed branch ${oldName} to ${newName}`);
toast.success(t('gitView.toast.renamedBranch', { oldName, newName }));
await refreshStatusAndBranches();
await refreshLog();
} catch (err) {
const message =
err instanceof Error ? err.message : `Failed to rename branch ${oldName} to ${newName}`;
err instanceof Error ? err.message : t('gitView.toast.renameBranchFailed', { oldName, newName });
toast.error(message);
}
};
@@ -1147,7 +1151,7 @@ export const GitView: React.FC = () => {
// Block mutation if worktree is in an attention-required state
const blockingReasons = getMutationBlockingReasons(worktreeAttachment);
if (blockingReasons.length > 0) {
toast.error(`Cannot checkout: ${formatBlockingReason(blockingReasons[0])}`);
toast.error(t('gitView.toast.cannotCheckout', { reason: formatBlockingReason(blockingReasons[0]) }));
return;
}
@@ -1159,12 +1163,12 @@ export const GitView: React.FC = () => {
try {
await git.checkoutBranch(currentDirectory, normalized);
toast.success(`Checked out ${normalized}`);
toast.success(t('gitView.toast.checkedOut', { name: normalized }));
await refreshStatusAndBranches();
await refreshLog();
} catch (err) {
const message =
err instanceof Error ? err.message : `Failed to checkout ${normalized}`;
err instanceof Error ? err.message : t('gitView.toast.checkoutFailed', { name: normalized });
toast.error(message);
}
};
@@ -1175,10 +1179,10 @@ export const GitView: React.FC = () => {
try {
await git.setGitIdentity(currentDirectory, profile.id);
toast.success(`Applied "${profile.name}" to repository`);
toast.success(t('gitView.toast.appliedIdentity', { name: profile.name }));
await refreshIdentity();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to apply git identity';
const message = err instanceof Error ? err.message : t('gitView.toast.applyIdentityFailed');
toast.error(message);
} finally {
endIdentityApply();
@@ -1492,10 +1496,10 @@ export const GitView: React.FC = () => {
try {
await git.revertGitFile(currentDirectory, filePath);
toast.success(`Reverted ${filePath}`);
toast.success(t('gitView.toast.revertedFile', { path: filePath }));
await refreshStatusAndBranches(false);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to revert changes';
const message = err instanceof Error ? err.message : t('gitView.toast.revertFailed');
toast.error(message);
} finally {
setRevertingPaths((previous) => {
@@ -1531,7 +1535,7 @@ export const GitView: React.FC = () => {
} catch (err) {
failed.push({
path: filePath,
message: err instanceof Error ? err.message : 'Failed to revert changes',
message: err instanceof Error ? err.message : t('gitView.toast.revertFailed'),
});
}
}));
@@ -1539,12 +1543,20 @@ export const GitView: React.FC = () => {
await refreshStatusAndBranches(false);
if (failed.length === 0) {
toast.success(`Reverted ${uniquePaths.length} file${uniquePaths.length === 1 ? '' : 's'}`);
toast.success(
uniquePaths.length === 1
? t('gitView.toast.revertedFilesSingle', { count: uniquePaths.length })
: t('gitView.toast.revertedFilesPlural', { count: uniquePaths.length })
);
} else if (failed.length === uniquePaths.length) {
toast.error(failed[0]?.message || 'Failed to revert changes');
toast.error(failed[0]?.message || t('gitView.toast.revertFailed'));
} else {
const successCount = uniquePaths.length - failed.length;
toast.warning(`Reverted ${successCount} file${successCount === 1 ? '' : 's'}, ${failed.length} failed`);
toast.warning(
successCount === 1
? t('gitView.toast.revertedSomeSingle', { success: successCount, failed: failed.length })
: t('gitView.toast.revertedSomePlural', { success: successCount, failed: failed.length })
);
}
} finally {
setRevertingPaths((previous) => {
@@ -1755,10 +1767,10 @@ export const GitView: React.FC = () => {
try {
if (conflictOperation === 'merge') {
await git.abortMerge(currentDirectory);
toast.success('Merge aborted');
toast.success(t('gitView.toast.mergeAborted'));
} else {
await git.abortRebase(currentDirectory);
toast.success('Rebase aborted');
toast.success(t('gitView.toast.rebaseAborted'));
}
clearConflictState();
await refreshStatusAndBranches();
@@ -1793,10 +1805,10 @@ export const GitView: React.FC = () => {
setConflictOperation('merge');
setConflictDialogOpen(true);
persistConflictState(currentDirectory, result.conflictFiles ?? [], 'merge');
toast.error('Merge conflicts detected');
toast.error(t('gitView.toast.mergeConflictsDetected'));
} else {
clearConflictState();
toast.success('Merge completed');
toast.success(t('gitView.toast.mergeCompleted'));
await refreshStatusAndBranches();
await refreshLog();
}
@@ -1807,16 +1819,16 @@ export const GitView: React.FC = () => {
setConflictOperation('rebase');
setConflictDialogOpen(true);
persistConflictState(currentDirectory, result.conflictFiles ?? [], 'rebase');
toast.error('Rebase conflicts detected');
toast.error(t('gitView.toast.rebaseConflictsDetected'));
} else {
clearConflictState();
toast.success('Rebase step completed');
toast.success(t('gitView.toast.rebaseStepCompleted'));
await refreshStatusAndBranches();
await refreshLog();
}
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to continue operation';
const message = err instanceof Error ? err.message : t('gitView.toast.continueOperationFailed');
toast.error(message);
}
}, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, persistConflictState, clearConflictState]);
@@ -1828,16 +1840,16 @@ export const GitView: React.FC = () => {
const isMerge = !!status?.mergeInProgress?.head;
if (isMerge) {
await git.abortMerge(currentDirectory);
toast.success('Merge aborted');
toast.success(t('gitView.toast.mergeAborted'));
} else {
await git.abortRebase(currentDirectory);
toast.success('Rebase aborted');
toast.success(t('gitView.toast.rebaseAborted'));
}
clearConflictState();
await refreshStatusAndBranches();
await refreshLog();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to abort operation';
const message = err instanceof Error ? err.message : t('gitView.toast.abortOperationFailed');
toast.error(message);
}
}, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, clearConflictState]);
@@ -1896,7 +1908,7 @@ export const GitView: React.FC = () => {
setConflictDialogOpen(true);
} else {
operationSucceeded = true;
toast.success(`Merged ${branch} into ${currentBranch}`);
toast.success(t('gitView.toast.mergedIntoBranch', { branch, currentBranch: currentBranch || '' }));
}
} else {
const result = await git.rebase(currentDirectory, { onto: branch });
@@ -1907,7 +1919,7 @@ export const GitView: React.FC = () => {
setConflictDialogOpen(true);
} else {
operationSucceeded = true;
toast.success(`Rebased ${currentBranch} onto ${branch}`);
toast.success(t('gitView.toast.rebasedOntoBranch', { currentBranch: currentBranch || '', branch }));
}
}
@@ -1915,13 +1927,13 @@ export const GitView: React.FC = () => {
if (restoreAfter && operationSucceeded) {
try {
await git.stashPop(currentDirectory);
toast.success('Stashed changes restored');
toast.success(t('gitView.toast.stashedRestored'));
} catch (popErr) {
const popMessage = popErr instanceof Error ? popErr.message : 'Failed to restore stashed changes';
const popMessage = popErr instanceof Error ? popErr.message : t('gitView.toast.restoreStashFailed');
toast.error(popMessage);
}
} else if (restoreAfter && hasConflict) {
toast.info('Stashed changes will need to be restored manually after resolving conflicts');
toast.info(t('gitView.toast.restoreStashManually'));
}
await refreshStatusAndBranches();
@@ -1945,7 +1957,7 @@ export const GitView: React.FC = () => {
return (
<div className="flex h-full items-center justify-center px-4 text-center">
<p className="typography-ui-label text-muted-foreground">
Select a session or directory to view repository details.
{t('gitView.empty.selectSessionOrDirectory')}
</p>
</div>
);
@@ -1956,7 +1968,7 @@ export const GitView: React.FC = () => {
<div className="flex h-full items-center justify-center">
<div className="flex items-center gap-2 text-muted-foreground">
<RiLoader4Line className="size-4 animate-spin" />
<span className="typography-ui-label">Checking repository...</span>
<span className="typography-ui-label">{t('gitView.loading.checkingRepository')}</span>
</div>
</div>
);
@@ -1968,10 +1980,10 @@ export const GitView: React.FC = () => {
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
<RiLoader4Line className="mb-3 size-6 animate-spin text-muted-foreground" />
<p className="typography-ui-label font-semibold text-foreground">
Worktree setup is in progress
{t('gitView.empty.worktreeSetupInProgress')}
</p>
<p className="typography-meta mt-1 text-muted-foreground">
Git tools will appear as soon as the new worktree is ready.
{t('gitView.empty.worktreeSetupDescription')}
</p>
</div>
);
@@ -1981,14 +1993,14 @@ export const GitView: React.FC = () => {
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
<RiGitBranchLine className="mb-3 size-6 text-muted-foreground" />
<p className="typography-ui-label font-semibold text-foreground">
Not a Git repository
{t('gitView.empty.notGitRepository')}
</p>
<p className="typography-meta mt-1 text-muted-foreground">
Choose a different directory or initialize Git to use this workspace.
{t('gitView.empty.notGitRepositoryDescription')}
</p>
{repairActions.includes('open-without-worktree-features') ? (
<p className="typography-meta mt-2 text-muted-foreground">
Worktree features are unavailable for this session.
{t('gitView.empty.worktreeFeaturesUnavailable')}
</p>
) : null}
</div>
@@ -2136,7 +2148,7 @@ export const GitView: React.FC = () => {
onOperationComplete={handleOperationComplete}
/>
) : (
<p className="typography-meta text-muted-foreground">Branch actions unavailable.</p>
<p className="typography-meta text-muted-foreground">{t('gitView.branch.actionsUnavailable')}</p>
)}
</div>
) : null}
@@ -2161,9 +2173,9 @@ export const GitView: React.FC = () => {
/>
) : (
<div className="space-y-1 pt-3">
<div className="typography-ui-header font-semibold text-foreground">Re-integrate commits</div>
<div className="typography-ui-header font-semibold text-foreground">{t('gitView.integrate.title')}</div>
<div className="typography-micro text-muted-foreground">
Available in worktree mode.
{t('gitView.worktree.availableInWorktreeMode')}
</div>
</div>
)}
@@ -2184,9 +2196,9 @@ export const GitView: React.FC = () => {
/>
) : (
<div className="space-y-1">
<div className="typography-ui-header font-semibold text-foreground">Pull Request</div>
<div className="typography-ui-header font-semibold text-foreground">{t('gitView.pullRequest.title')}</div>
<div className="typography-micro text-muted-foreground">
Push a non-base branch (with upstream) to create a PR.
{t('gitView.pullRequest.createHint')}
</div>
</div>
)}
@@ -2200,9 +2212,9 @@ export const GitView: React.FC = () => {
<Dialog open={isHistoryDialogOpen} onOpenChange={setIsHistoryDialogOpen}>
<DialogContent className="max-w-5xl max-h-[80vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>History</DialogTitle>
<DialogTitle>{t('gitView.history.title')}</DialogTitle>
<DialogDescription>
Browse recent commits and inspect file-level changes.
{t('gitView.history.dialogDescription')}
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0">
@@ -2226,16 +2238,16 @@ export const GitView: React.FC = () => {
<Dialog open={isGitmojiPickerOpen} onOpenChange={setIsGitmojiPickerOpen}>
<DialogContent className="max-w-md p-0 overflow-hidden">
<DialogHeader className="px-4 pt-4">
<DialogTitle>Pick a gitmoji</DialogTitle>
<DialogTitle>{t('gitView.gitmoji.title')}</DialogTitle>
</DialogHeader>
<Command className="h-[420px]">
<CommandInput
placeholder="Search gitmojis..."
placeholder={t('gitView.gitmoji.searchPlaceholder')}
value={gitmojiSearch}
onValueChange={setGitmojiSearch}
/>
<CommandList>
<CommandEmpty>No gitmojis found.</CommandEmpty>
<CommandEmpty>{t('gitView.gitmoji.empty')}</CommandEmpty>
<CommandGroup>
{(gitmojiEmojis.length === 0
? []
@@ -5,6 +5,7 @@ import { EditorView } from '@codemirror/view';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
type GoToLineDialogProps = {
@@ -54,6 +55,7 @@ const moveSelectionToLine = (view: EditorView, lineNumber: number, preferredChar
};
export function GoToLineDialog({ open, onOpenChange, view, variant = 'overlay' }: GoToLineDialogProps) {
const { t } = useI18n();
const [inputValue, setInputValue] = React.useState('');
const initialCursorRef = React.useRef<CursorSnapshot | null>(null);
const committedRef = React.useRef(false);
@@ -165,16 +167,19 @@ export function GoToLineDialog({ open, onOpenChange, view, variant = 'overlay' }
const helperText = React.useMemo(() => {
if (!view) {
return 'Editor unavailable.';
return t('goToLineDialog.helper.editorUnavailable');
}
if (lineNumber === null) {
const snapshot = initialCursorRef.current ?? getCursorSnapshot(view, view.state.selection);
return `Current Line: ${snapshot.lineNumber}. Type a line number between 1 and ${view.state.doc.lines} to navigate to.`;
return t('goToLineDialog.helper.currentLineRange', {
current: snapshot.lineNumber,
max: view.state.doc.lines,
});
}
return `Go to line ${lineNumber}`;
}, [lineNumber, view]);
return t('goToLineDialog.helper.goToLine', { line: lineNumber });
}, [lineNumber, t, view]);
if (variant === 'inline') {
if (!open) {
@@ -200,7 +205,7 @@ export function GoToLineDialog({ open, onOpenChange, view, variant = 'overlay' }
handleSubmit();
}
}}
placeholder="Line"
placeholder={t('goToLineDialog.field.linePlaceholderShort')}
className="h-6 w-20 rounded-md border-border/70 bg-transparent px-2 typography-meta"
/>
<Button
@@ -210,7 +215,7 @@ export function GoToLineDialog({ open, onOpenChange, view, variant = 'overlay' }
disabled={!view || lineNumber === null}
className="h-6 px-2"
>
Go
{t('goToLineDialog.actions.go')}
</Button>
</div>
);
@@ -239,7 +244,7 @@ export function GoToLineDialog({ open, onOpenChange, view, variant = 'overlay' }
handleSubmit();
}
}}
placeholder="Line number"
placeholder={t('goToLineDialog.field.linePlaceholder')}
className="h-8 w-full rounded-md border-border/70 bg-background/60 typography-ui-label"
/>
<div className="mt-2 rounded-md bg-primary/15 px-3 py-1.5 typography-ui-label text-foreground/95">
@@ -3,6 +3,7 @@ import { Dialog } from '@base-ui/react/dialog';
import { RiCloseLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { MultiRunLauncher } from '@/components/multirun';
import { useI18n } from '@/lib/i18n';
interface MultiRunWindowProps {
open: boolean;
@@ -16,6 +17,7 @@ export const MultiRunWindow: React.FC<MultiRunWindowProps> = ({
initialPrompt,
}) => {
const descriptionId = React.useId();
const { t } = useI18n();
const hasOpenFloatingMenu = React.useCallback(() => {
if (typeof document === 'undefined') {
@@ -50,14 +52,14 @@ export const MultiRunWindow: React.FC<MultiRunWindowProps> = ({
<button
type="button"
onClick={() => onOpenChange(false)}
aria-label="Close multi-run"
aria-label={t('multiRun.window.actions.closeAria')}
className="inline-flex h-7 w-7 items-center justify-center rounded-md p-0.5 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<RiCloseLine className="h-5 w-5" />
</button>
</div>
<Dialog.Description id={descriptionId} className="sr-only">
OpenChamber Multi-Run window.
{t('multiRun.window.description')}
</Dialog.Description>
<MultiRunLauncher
initialPrompt={initialPrompt}
+26 -24
View File
@@ -39,6 +39,7 @@ import { parseProjectPlanMarkdown } from '@/lib/openchamberConfig';
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
import { TodoSendDialog, type TodoSendExecution } from '@/components/session/TodoSendDialog';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { useI18n } from '@/lib/i18n';
type PlanViewProps = {
targetPath?: string | null;
@@ -142,6 +143,7 @@ type SelectedLineRange = {
};
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const createSession = useSessionUIStore((state) => state.createSession);
const initializeNewOpenChamberSession = useSessionUIStore((state) => state.initializeNewOpenChamberSession);
@@ -195,15 +197,15 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
const [content, setContent] = React.useState<string>('');
const [saveError, setSaveError] = React.useState<string | null>(null);
const planFileLabel = React.useMemo(() => {
return displayPath ? displayPath.split('/').pop() || 'plan' : 'plan';
}, [displayPath]);
return displayPath ? displayPath.split('/').pop() || t('planView.file.defaultName') : t('planView.file.defaultName');
}, [displayPath, t]);
const parsedTitle = React.useMemo(() => {
if (!content.trim()) {
return 'Plan';
return t('planView.title.default');
}
return parseProjectPlanMarkdown(content).title || 'Plan';
}, [content]);
const sendPromptTitle = React.useMemo(() => parsedTitle.trim() || 'Plan', [parsedTitle]);
return parseProjectPlanMarkdown(content).title || t('planView.title.default');
}, [content, t]);
const sendPromptTitle = React.useMemo(() => parsedTitle.trim() || t('planView.title.default'), [parsedTitle, t]);
const [loading, setLoading] = React.useState(false);
const [copiedContent, setCopiedContent] = React.useState(false);
const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit');
@@ -461,7 +463,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
if (runtimeApis.files?.writeFile) {
const result = await runtimeApis.files.writeFile(resolvedPath, content);
if (!result?.success) {
throw new Error('Write failed');
throw new Error(t('planView.error.writeFailed'));
}
} else {
const response = await fetch('/api/fs/write', {
@@ -470,18 +472,18 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
body: JSON.stringify({ path: resolvedPath, content }),
});
if (!response.ok) {
throw new Error(`Failed to write plan file (${response.status})`);
throw new Error(t('planView.error.writePlanFileFailed', { status: response.status }));
}
}
} catch (error) {
setSaveError(error instanceof Error ? error.message : 'Failed to save');
setSaveError(error instanceof Error ? error.message : t('planView.error.saveFailed'));
}
}, 350);
return () => {
window.clearTimeout(controller);
};
}, [content, resolvedPath, runtimeApis.files]);
}, [content, resolvedPath, runtimeApis.files, t]);
React.useEffect(() => {
return () => {
@@ -609,7 +611,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
<div className="typography-ui-label font-medium truncate">{parsedTitle}</div>
{saveError ? (
<div className="typography-micro text-[color:var(--status-error)] truncate" title={saveError}>
Save failed
{t('planView.error.saveFailed')}
</div>
) : null}
</div>
@@ -623,24 +625,24 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
aria-label="Improve plan"
aria-label={t('planView.actions.improvePlanAria')}
disabled={!content.trim()}
>
<RiLoopRightAiLine className="size-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Improve</TooltipContent>
<TooltipContent sideOffset={8}>{t('planView.actions.improve')}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setPendingPlanSend({ action: 'improve', target: 'session' })}>
Send to new session
{t('planView.actions.sendToNewSession')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setPendingPlanSend({ action: 'improve', target: 'worktree' })}
disabled={!canCreateWorktree}
>
Send to new worktree session
{t('planView.actions.sendToNewWorktreeSession')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -652,24 +654,24 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
aria-label="Implement plan"
aria-label={t('planView.actions.implementPlanAria')}
disabled={!content.trim()}
>
<RiCodeAiLine className="size-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Implement</TooltipContent>
<TooltipContent sideOffset={8}>{t('planView.actions.implement')}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setPendingPlanSend({ action: 'implement', target: 'session' })}>
Send to new session
{t('planView.actions.sendToNewSession')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setPendingPlanSend({ action: 'implement', target: 'worktree' })}
disabled={!canCreateWorktree}
>
Send to new worktree session
{t('planView.actions.sendToNewWorktreeSession')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -695,8 +697,8 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
}
}}
className="h-5 w-5 p-0"
title="Copy plan contents"
aria-label="Copy plan contents"
title={t('planView.actions.copyPlanContents')}
aria-label={t('planView.actions.copyPlanContents')}
>
{copiedContent ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
@@ -724,7 +726,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
<div className="flex-1 min-h-0 min-w-0 relative">
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{loading ? (
<div className="p-3 typography-ui text-muted-foreground">Loading</div>
<div className="p-3 typography-ui text-muted-foreground">{t('planView.state.loading')}</div>
) : (
<div className="relative h-full">
<div className="h-full">
@@ -733,9 +735,9 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
<ErrorBoundary
fallback={
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
<div className="mb-1 font-medium text-destructive">Preview unavailable</div>
<div className="mb-1 font-medium text-destructive">{t('planView.error.previewUnavailable')}</div>
<div className="text-sm text-muted-foreground">
Switch to edit mode to fix the issue.
{t('planView.error.switchToEditMode')}
</div>
</div>
}
@@ -57,6 +57,7 @@ import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPa
import { McpIcon } from '@/components/icons/McpIcon';
import { useDeviceInfo } from '@/lib/device';
import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import {
SETTINGS_PAGE_METADATA,
@@ -167,12 +168,13 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): React.ComponentType<
}
const SettingsHome: React.FC<{ onOpen: (slug: SettingsPageSlug) => void }> = ({ onOpen }) => {
const { t } = useI18n();
return (
<div className="h-full overflow-auto">
<div className="mx-auto w-full max-w-3xl px-6 py-6 space-y-6">
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-foreground">Settings</h1>
<p className="typography-ui text-muted-foreground">Jump to common pages.</p>
<h1 className="typography-ui-header font-semibold text-foreground">{t('settings.view.home.title')}</h1>
<p className="typography-ui text-muted-foreground">{t('settings.view.home.description')}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
@@ -184,8 +186,8 @@ const SettingsHome: React.FC<{ onOpen: (slug: SettingsPageSlug) => void }> = ({
'hover:bg-[var(--interactive-hover)] transition-colors'
)}
>
<div className="typography-ui-label text-foreground">Providers</div>
<div className="typography-micro text-muted-foreground/70">Connect models + credentials</div>
<div className="typography-ui-label text-foreground">{t('settings.view.home.cards.providers.title')}</div>
<div className="typography-micro text-muted-foreground/70">{t('settings.view.home.cards.providers.description')}</div>
</button>
<button
@@ -196,8 +198,8 @@ const SettingsHome: React.FC<{ onOpen: (slug: SettingsPageSlug) => void }> = ({
'hover:bg-[var(--interactive-hover)] transition-colors'
)}
>
<div className="typography-ui-label text-foreground">Agents</div>
<div className="typography-micro text-muted-foreground/70">Prompts, tools, permissions</div>
<div className="typography-ui-label text-foreground">{t('settings.view.home.cards.agents.title')}</div>
<div className="typography-micro text-muted-foreground/70">{t('settings.view.home.cards.agents.description')}</div>
</button>
<button
@@ -208,8 +210,8 @@ const SettingsHome: React.FC<{ onOpen: (slug: SettingsPageSlug) => void }> = ({
'hover:bg-[var(--interactive-hover)] transition-colors'
)}
>
<div className="typography-ui-label text-foreground">Skills Catalog</div>
<div className="typography-micro text-muted-foreground/70">Install skills from catalogs</div>
<div className="typography-ui-label text-foreground">{t('settings.view.home.cards.skillsCatalog.title')}</div>
<div className="typography-micro text-muted-foreground/70">{t('settings.view.home.cards.skillsCatalog.description')}</div>
</button>
<button
@@ -220,8 +222,8 @@ const SettingsHome: React.FC<{ onOpen: (slug: SettingsPageSlug) => void }> = ({
'hover:bg-[var(--interactive-hover)] transition-colors'
)}
>
<div className="typography-ui-label text-foreground">MCP</div>
<div className="typography-micro text-muted-foreground/70">Configure MCP servers + connections</div>
<div className="typography-ui-label text-foreground">{t('settings.view.home.cards.mcp.title')}</div>
<div className="typography-micro text-muted-foreground/70">{t('settings.view.home.cards.mcp.description')}</div>
</button>
<button
@@ -232,8 +234,8 @@ const SettingsHome: React.FC<{ onOpen: (slug: SettingsPageSlug) => void }> = ({
'hover:bg-[var(--interactive-hover)] transition-colors'
)}
>
<div className="typography-ui-label text-foreground">Usage</div>
<div className="typography-micro text-muted-foreground/70">Quota + spend visibility</div>
<div className="typography-ui-label text-foreground">{t('settings.view.home.cards.usage.title')}</div>
<div className="typography-micro text-muted-foreground/70">{t('settings.view.home.cards.usage.description')}</div>
</button>
</div>
</div>
@@ -242,6 +244,7 @@ const SettingsHome: React.FC<{ onOpen: (slug: SettingsPageSlug) => void }> = ({
};
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed }) => {
const { t } = useI18n();
const deviceInfo = useDeviceInfo();
const isMobile = forceMobile ?? deviceInfo.isMobile;
@@ -382,16 +385,60 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
tunnel: 'tunnel',
}), []);
const getPageTitle = React.useCallback((slug: SettingsPageSlug): string => {
switch (slug) {
case 'projects':
return t('settings.page.projects.title');
case 'remote-instances':
return t('settings.page.remoteInstances.title');
case 'providers':
return t('settings.page.providers.title');
case 'usage':
return t('settings.page.usage.title');
case 'agents':
return t('settings.page.agents.title');
case 'commands':
return t('settings.page.commands.title');
case 'mcp':
return t('settings.page.mcp.title');
case 'skills.installed':
return t('settings.page.skills.title');
case 'skills.catalog':
return t('settings.page.skillsCatalog.title');
case 'git':
return t('settings.page.git.title');
case 'appearance':
return t('settings.page.appearance.title');
case 'chat':
return t('settings.page.chat.title');
case 'shortcuts':
return t('settings.page.shortcuts.title');
case 'sessions':
return t('settings.page.sessions.title');
case 'magic-prompts':
return t('settings.page.magicPrompts.title');
case 'notifications':
return t('settings.page.notifications.title');
case 'voice':
return t('settings.page.voice.title');
case 'tunnel':
return t('settings.page.tunnel.title');
case 'home':
default:
return t('settings.view.home.title');
}
}, [t]);
const renderUnavailable = React.useCallback(() => {
return (
<div className="flex h-full items-center justify-center px-6">
<div className="max-w-md text-center">
<div className="typography-ui-header font-semibold text-foreground">Not available</div>
<p className="typography-ui text-muted-foreground mt-1">This settings page is not available in this runtime.</p>
<div className="typography-ui-header font-semibold text-foreground">{t('settings.view.unavailable.title')}</div>
<p className="typography-ui text-muted-foreground mt-1">{t('settings.view.unavailable.description')}</p>
</div>
</div>
);
}, []);
}, [t]);
const renderPageSidebar = React.useCallback((slug: SettingsPageSlug, opts: { onItemSelect?: () => void }) => {
switch (slug) {
@@ -528,10 +575,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
collapsed ? 'opacity-0' : 'opacity-100'
)}
>
<span className="typography-ui-label font-normal truncate">{page.title}</span>
<span className="typography-ui-label font-normal truncate">{getPageTitle(page.slug)}</span>
{(page.slug === 'voice' || page.slug === 'tunnel') && (
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">
beta
{t('settings.view.badge.beta')}
</span>
)}
</span>
@@ -539,7 +586,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
</TooltipTrigger>
{collapsed && (
<TooltipContent side="right" sideOffset={8}>
{page.title}
{getPageTitle(page.slug)}
</TooltipContent>
)}
</Tooltip>
@@ -569,11 +616,11 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
onClick={() => void reloadOpenCodeConfiguration({ message: 'Restarting OpenCode…', mode: 'projects', scopes: ['all'] })}
>
<RiRestartLine className="h-4 w-4 shrink-0" />
<span>Reload OpenCode</span>
<span>{t('settings.view.actions.reloadOpenCode')}</span>
</button>
</TooltipTrigger>
<TooltipContent>
Restart OpenCode and reload its configuration.
{t('settings.view.actions.reloadOpenCodeTooltip')}
</TooltipContent>
</Tooltip>
)}
@@ -666,7 +713,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
<button
type="button"
onClick={showBackButton ? handleBack : onClose}
aria-label={showBackButton ? 'Back to Settings' : 'Close settings'}
aria-label={showBackButton ? t('settings.view.actions.backToSettings') : t('settings.view.actions.closeSettings')}
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<RiArrowLeftSLine className="h-5 w-5" />
@@ -674,15 +721,15 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
<div className="min-w-0 flex-1 typography-ui-label font-medium text-foreground truncate">
{mobileStage === 'nav'
? 'Settings'
: (activePageMeta?.title ?? 'Settings')}
? t('settings.view.home.title')
: (activePageMeta ? getPageTitle(activePageMeta.slug) : t('settings.view.home.title'))}
</div>
{mobileStage === 'page-content' && activePageMeta?.kind === 'split' && (
<button
type="button"
onClick={handleOpenPageSidebar}
aria-label="Open section list"
aria-label={t('settings.view.actions.openSectionList')}
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<RiListUnordered className="h-5 w-5" />
@@ -693,8 +740,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
<button
type="button"
onClick={onClose}
aria-label="Close settings"
title={`Close Settings (${shortcutKey}+,)`}
aria-label={t('settings.view.actions.closeSettings')}
title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })}
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<RiCloseLine className="h-5 w-5" />
@@ -708,7 +755,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
<button
type="button"
onClick={handleBack}
aria-label="Back"
aria-label={t('settings.view.actions.back')}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<RiArrowLeftSLine className="h-5 w-5" />
@@ -721,8 +768,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
<button
type="button"
onClick={onClose}
aria-label="Close settings"
title={`Close Settings (${shortcutKey}+,)`}
aria-label={t('settings.view.actions.closeSettings')}
title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })}
className="inline-flex h-7 w-7 items-center justify-center rounded-md p-0.5 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<RiCloseLine className="h-5 w-5" />
@@ -762,7 +809,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
onPointerDown={handlePointerDown}
role="separator"
aria-orientation="vertical"
aria-label="Resize settings navigation"
aria-label={t('settings.view.actions.resizeNavigation')}
/>
)}
<ErrorBoundary>
@@ -1,6 +1,7 @@
import React from 'react';
import { Dialog } from '@base-ui/react/dialog';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { SettingsView } from './SettingsView';
interface SettingsWindowProps {
@@ -13,6 +14,7 @@ interface SettingsWindowProps {
* Used for desktop and web (non-mobile) environments.
*/
export const SettingsWindow: React.FC<SettingsWindowProps> = ({ open, onOpenChange }) => {
const { t } = useI18n();
const descriptionId = React.useId();
const hasOpenFloatingMenu = React.useCallback(() => {
@@ -45,7 +47,7 @@ export const SettingsWindow: React.FC<SettingsWindowProps> = ({ open, onOpenChan
)}
>
<Dialog.Description id={descriptionId} className="sr-only">
OpenChamber settings window.
{t('settings.window.description')}
</Dialog.Description>
<SettingsView onClose={() => onOpenChange(false)} isWindowed />
</Dialog.Popup>
@@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button';
import { useDeviceInfo } from '@/lib/device';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { primeTerminalInputTransport } from '@/lib/terminalApi';
import { useI18n } from '@/lib/i18n';
type Modifier = 'ctrl' | 'cmd';
type MobileKey =
@@ -81,6 +82,7 @@ const getSequenceForKey = (key: MobileKey, modifier: Modifier | null): string |
};
export const TerminalView: React.FC = () => {
const { t } = useI18n();
const { terminal, runtime } = useRuntimeAPIs();
const { currentTheme } = useThemeSystem();
const { monoFont } = useFontPreferences();
@@ -313,14 +315,21 @@ export const TerminalView: React.FC = () => {
appendToBuffer(
directory,
tabId,
`\r\n[Process exited${
exitCode !== null ? ` with code ${exitCode}` : ''
}${signal !== null ? ` (signal ${signal})` : ''}]\r\n`
t('terminalView.stream.processExitedMessage', {
exitCodeSegment:
exitCode !== null
? t('terminalView.stream.processExitedWithCode', { exitCode })
: '',
signalSegment:
signal !== null
? t('terminalView.stream.processExitedWithSignal', { signal })
: '',
})
);
setTabLifecycle(directory, tabId, 'exited');
setTabSessionId(directory, tabId, null);
setConnecting(directory, tabId, false);
setConnectionError(isActionTab ? null : 'Terminal session ended');
setConnectionError(isActionTab ? null : t('terminalView.error.sessionEnded'));
setIsFatalError(false);
setIsReconnectPending(false);
disconnectStream();
@@ -340,7 +349,9 @@ export const TerminalView: React.FC = () => {
}
setIsReconnectPending(false);
setConnectionError(`Connection failed: ${error.message}`);
setConnectionError(
t('terminalView.error.connectionFailed', { message: error.message })
);
setIsFatalError(true);
setConnecting(directory, tabId, false);
setTabLifecycle(directory, tabId, 'exited');
@@ -356,7 +367,16 @@ export const TerminalView: React.FC = () => {
activeTerminalIdRef.current = null;
};
},
[appendToBuffer, disconnectStream, focusTerminalWhenWindowActive, setConnecting, setTabLifecycle, setTabSessionId, terminal]
[
appendToBuffer,
disconnectStream,
focusTerminalWhenWindowActive,
setConnecting,
setTabLifecycle,
setTabSessionId,
t,
terminal,
]
);
React.useEffect(() => {
@@ -369,8 +389,8 @@ export const TerminalView: React.FC = () => {
if (!effectiveDirectory) {
setConnectionError(
hasActiveContext
? 'No working directory available for terminal.'
: 'Select a session to open the terminal.'
? t('terminalView.empty.noWorkingDirectory')
: t('terminalView.empty.selectSession')
);
disconnectStream();
return;
@@ -451,7 +471,7 @@ export const TerminalView: React.FC = () => {
setConnectionError(
error instanceof Error
? error.message
: 'Failed to start terminal session'
: t('terminalView.error.startSessionFailed')
);
setIsFatalError(true);
setIsReconnectPending(false);
@@ -502,6 +522,7 @@ export const TerminalView: React.FC = () => {
setTabSessionId,
startStream,
disconnectStream,
t,
terminal,
]);
@@ -544,13 +565,15 @@ export const TerminalView: React.FC = () => {
try {
await closeTab(effectiveDirectory, tabId);
} catch (error) {
setConnectionError(error instanceof Error ? error.message : 'Failed to restart terminal');
setConnectionError(
error instanceof Error ? error.message : t('terminalView.error.restartFailed')
);
setIsFatalError(true);
setIsReconnectPending(false);
} finally {
setIsRestarting(false);
}
}, [activeTabId, closeTab, disconnectStream, effectiveDirectory, enableTabs, isRestarting]);
}, [activeTabId, closeTab, disconnectStream, effectiveDirectory, enableTabs, isRestarting, t]);
const handleHardRestart = React.useCallback(async () => {
// Keep semantics: “close tab -> new clean tab”.
@@ -625,7 +648,9 @@ export const TerminalView: React.FC = () => {
void terminal.sendInput(terminalId, payload).catch((error) => {
if (!isReconnectPending) {
setConnectionError(error instanceof Error ? error.message : 'Failed to send input');
setConnectionError(
error instanceof Error ? error.message : t('terminalView.error.sendInputFailed')
);
}
});
@@ -634,7 +659,7 @@ export const TerminalView: React.FC = () => {
terminalControllerRef.current?.focus();
}
},
[activeModifier, isReconnectPending, setActiveModifier, terminal]
[activeModifier, isReconnectPending, setActiveModifier, t, terminal]
);
const handleViewportResize = React.useCallback(
@@ -860,7 +885,7 @@ export const TerminalView: React.FC = () => {
if (!hasActiveContext) {
return (
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-muted-foreground">
Select a session to open the terminal
{t('terminalView.empty.selectSession')}
</div>
);
}
@@ -868,12 +893,12 @@ export const TerminalView: React.FC = () => {
if (!effectiveDirectory) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">
<p>No working directory available for this session.</p>
<p>{t('terminalView.empty.noWorkingDirectoryForSession')}</p>
<button
onClick={handleRestart}
className="rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Retry
{t('terminalView.actions.retry')}
</button>
</div>
);
@@ -891,7 +916,7 @@ export const TerminalView: React.FC = () => {
onClick={() => handleMobileKeyPress('esc')}
disabled={quickKeysDisabled}
>
Esc
{t('terminalView.quickKeys.escape')}
</Button>
<Button
type="button"
@@ -902,7 +927,7 @@ export const TerminalView: React.FC = () => {
disabled={quickKeysDisabled}
>
<RiArrowRightLine size={16} />
<span className="sr-only">Tab</span>
<span className="sr-only">{t('terminalView.quickKeys.tabAria')}</span>
</Button>
<Button
type="button"
@@ -913,8 +938,8 @@ export const TerminalView: React.FC = () => {
onClick={() => handleModifierToggle('ctrl')}
disabled={quickKeysDisabled}
>
<span className="text-xs font-medium">Ctrl</span>
<span className="sr-only">Control modifier</span>
<span className="text-xs font-medium">{t('terminalView.quickKeys.controlLabel')}</span>
<span className="sr-only">{t('terminalView.quickKeys.controlModifierAria')}</span>
</Button>
<Button
type="button"
@@ -926,7 +951,7 @@ export const TerminalView: React.FC = () => {
disabled={quickKeysDisabled}
>
<RiCommandLine size={16} />
<span className="sr-only">Command modifier</span>
<span className="sr-only">{t('terminalView.quickKeys.commandModifierAria')}</span>
</Button>
<Button
type="button"
@@ -937,7 +962,7 @@ export const TerminalView: React.FC = () => {
disabled={quickKeysDisabled}
>
<RiArrowUpLine size={16} />
<span className="sr-only">Arrow up</span>
<span className="sr-only">{t('terminalView.quickKeys.arrowUpAria')}</span>
</Button>
<Button
type="button"
@@ -948,7 +973,7 @@ export const TerminalView: React.FC = () => {
disabled={quickKeysDisabled}
>
<RiArrowLeftLine size={16} />
<span className="sr-only">Arrow left</span>
<span className="sr-only">{t('terminalView.quickKeys.arrowLeftAria')}</span>
</Button>
<Button
type="button"
@@ -959,7 +984,7 @@ export const TerminalView: React.FC = () => {
disabled={quickKeysDisabled}
>
<RiArrowDownLine size={16} />
<span className="sr-only">Arrow down</span>
<span className="sr-only">{t('terminalView.quickKeys.arrowDownAria')}</span>
</Button>
<Button
type="button"
@@ -970,7 +995,7 @@ export const TerminalView: React.FC = () => {
disabled={quickKeysDisabled}
>
<RiArrowRightLine size={16} />
<span className="sr-only">Arrow right</span>
<span className="sr-only">{t('terminalView.quickKeys.arrowRightAria')}</span>
</Button>
<Button
type="button"
@@ -981,7 +1006,7 @@ export const TerminalView: React.FC = () => {
disabled={quickKeysDisabled}
>
<RiArrowGoBackLine size={16} />
<span className="sr-only">Enter</span>
<span className="sr-only">{t('terminalView.quickKeys.enterAria')}</span>
</Button>
</>
);
@@ -1028,7 +1053,7 @@ export const TerminalView: React.FC = () => {
e.stopPropagation();
handleCloseTab(tab.id);
}}
title="Close tab"
title={t('terminalView.tabs.closeTabTitle')}
>
{isMobile ? <span aria-hidden>×</span> : <RiCloseLine size={12} />}
</button>
@@ -1043,7 +1068,7 @@ export const TerminalView: React.FC = () => {
'ml-1 flex items-center justify-center rounded-md border border-[var(--interactive-border)] bg-transparent text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]',
isMobile ? '!min-h-0 !min-w-0 h-8 w-8' : 'h-6.5 w-6.5'
)}
title="New tab"
title={t('terminalView.tabs.newTabTitle')}
>
<RiAddLine size={isMobile ? 18 : 16} />
</button>
@@ -1098,10 +1123,10 @@ export const TerminalView: React.FC = () => {
className="h-6 px-2 py-0 text-xs"
onClick={handleHardRestart}
disabled={isRestarting}
title="Force kill and create fresh session"
title={t('terminalView.actions.hardRestartTitle')}
type="button"
>
Hard Restart
{t('terminalView.actions.hardRestart')}
</Button>
)}
</div>
@@ -31,6 +31,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useI18n } from '@/lib/i18n';
interface AgentGroupDetailProps {
group: AgentGroup;
@@ -52,6 +53,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
group,
className,
}) => {
const { t } = useI18n();
const selectedSessionId = useAgentGroupsStore((s) => s.selectedSessionId);
const selectSession = useAgentGroupsStore((s) => s.selectSession);
const deleteGroupSessions = useAgentGroupsStore((s) => s.deleteGroupSessions);
@@ -92,17 +94,17 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
const handleCopyWorktreePath = React.useCallback(() => {
if (!selectedSession?.path) {
toast.error('No worktree path available');
toast.error(t('agentManager.detail.toast.noWorktreePath'));
return;
}
void copyTextToClipboard(selectedSession.path).then((result) => {
if (result.ok) {
toast.success('Worktree path copied');
toast.success(t('agentManager.detail.toast.worktreePathCopied'));
return;
}
toast.error('Failed to copy path');
toast.error(t('agentManager.detail.toast.failedToCopyPath'));
});
}, [selectedSession?.path]);
}, [selectedSession?.path, t]);
const handleRemoveSelectedWorktree = React.useCallback(() => {
if (!selectedSession) return;
@@ -123,24 +125,28 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
let sessionsToDelete: AgentGroupSession[];
if (worktreeDialog.kind === 'remove') {
toast.info('Removing worktree...');
toast.info(t('agentManager.detail.toast.removingWorktree'));
sessionsToDelete = group.sessions.filter((s) => normalize(s.path) === targetPath);
} else {
toast.info('Removing other worktrees...');
toast.info(t('agentManager.detail.toast.removingOtherWorktrees'));
sessionsToDelete = group.sessions.filter((s) => normalize(s.path) !== targetPath);
}
const { failedIds, failedWorktreePaths } = await deleteGroupSessions(sessionsToDelete, { removeWorktrees: true });
if (failedIds.length > 0 || failedWorktreePaths.length > 0) {
toast.error('Failed to fully remove worktree');
toast.error(t('agentManager.detail.toast.failedToFullyRemoveWorktree'));
} else {
toast.success(worktreeDialog.kind === 'remove' ? 'Worktree removed' : 'Removed other worktrees');
toast.success(
worktreeDialog.kind === 'remove'
? t('agentManager.detail.toast.worktreeRemoved')
: t('agentManager.detail.toast.otherWorktreesRemoved')
);
}
setWorktreeDialog(null);
} finally {
setIsProcessing(false);
}
}, [deleteGroupSessions, group.sessions, isProcessing, worktreeDialog]);
}, [deleteGroupSessions, group.sessions, isProcessing, t, worktreeDialog]);
// Group-level status: show if any session is busy
const allStatuses = useAllSessionStatuses();
@@ -160,11 +166,15 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
{groupBusy && <RiLoader4Line className="h-4 w-4 animate-spin text-amber-500 flex-shrink-0" />}
</div>
<div className="flex items-center gap-2 mt-1 typography-meta text-muted-foreground">
<span>{group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''}</span>
<span>
{group.sessionCount === 1
? t('agentManager.detail.header.modelCountSingle', { count: group.sessionCount })
: t('agentManager.detail.header.modelCountPlural', { count: group.sessionCount })}
</span>
<span>·</span>
<span className="flex items-center gap-1">
<RiGitBranchLine className="h-3.5 w-3.5" />
{selectedSession?.worktreeMetadata?.label || selectedSession?.branch || 'No branch'}
{selectedSession?.worktreeMetadata?.label || selectedSession?.branch || t('agentManager.detail.header.noBranch')}
</span>
</div>
</div>
@@ -243,7 +253,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="flex-shrink-0" aria-label="Worktree actions">
<Button variant="outline" size="icon" className="flex-shrink-0" aria-label={t('agentManager.detail.actions.worktreeActionsAria')}>
<RiMore2Line className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -253,13 +263,13 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
closeOnClick={false}
variant="destructive"
>
Remove this worktree
{t('agentManager.detail.actions.removeThisWorktree')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleKeepOnlySelectedWorktree}
closeOnClick={false}
>
Leave this one, remove others
{t('agentManager.detail.actions.keepThisRemoveOthers')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
@@ -269,7 +279,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
disabled={!selectedSession?.path}
>
<RiFileCopyLine className="h-4 w-4 mr-px" />
Copy Worktree Path
{t('agentManager.detail.actions.copyWorktreePath')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -281,24 +291,33 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{worktreeDialog?.kind === 'remove' ? 'Remove worktree' : 'Remove other worktrees'}
{worktreeDialog?.kind === 'remove'
? t('agentManager.detail.dialog.removeWorktreeTitle')
: t('agentManager.detail.dialog.removeOtherWorktreesTitle')}
</DialogTitle>
<DialogDescription>
{worktreeDialog?.kind === 'remove'
? <>Remove <span className="text-foreground font-medium">{worktreeDialog?.label}</span>? This deletes all sessions in that worktree and removes the worktree itself.</>
: <>Keep <span className="text-foreground font-medium">{worktreeDialog?.label}</span> and remove the other worktrees in <span className="text-foreground font-medium">{group.name}</span>.</>}
? t('agentManager.detail.dialog.removeWorktreeDescription', { label: worktreeDialog?.label ?? '' })
: t('agentManager.detail.dialog.removeOtherWorktreesDescription', {
label: worktreeDialog?.label ?? '',
group: group.name,
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setWorktreeDialog(null)} disabled={isProcessing}>
Cancel
{t('agentManager.detail.dialog.cancel')}
</Button>
<Button
variant={worktreeDialog?.kind === 'remove' ? 'destructive' : 'default'}
onClick={() => void handleConfirmWorktreeAction()}
disabled={isProcessing}
>
{isProcessing ? 'Working…' : worktreeDialog?.kind === 'remove' ? 'Remove' : 'Remove others'}
{isProcessing
? t('agentManager.detail.dialog.working')
: worktreeDialog?.kind === 'remove'
? t('agentManager.detail.dialog.remove')
: t('agentManager.detail.dialog.removeOthers')}
</Button>
</DialogFooter>
</DialogContent>
@@ -328,10 +347,10 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
<div className="flex-1 flex items-center justify-center">
<div className="text-center p-8">
<p className="typography-body text-muted-foreground mb-2">
Loading session for <span className="font-medium text-foreground">{selectedSession.displayLabel}</span>
{t('agentManager.detail.state.loadingSessionFor', { label: selectedSession.displayLabel })}
</p>
<p className="typography-micro text-muted-foreground/60">
Session ID: {selectedSession.id}
{t('agentManager.detail.state.sessionId', { id: selectedSession.id })}
</p>
</div>
</div>
@@ -340,7 +359,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
) : (
<div className="h-full flex items-center justify-center">
<p className="typography-body text-muted-foreground">
No sessions in this group
{t('agentManager.detail.state.noSessionsInGroup')}
</p>
</div>
)}
@@ -27,6 +27,7 @@ import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import type { ProjectRef } from '@/lib/openchamberConfig';
import type { CreateMultiRunParams, MultiRunFileAttachment } from '@/types/multirun';
import { useI18n } from '@/lib/i18n';
/** Max file size in bytes (10MB) */
const MAX_FILE_SIZE = 10 * 1024 * 1024;
@@ -55,6 +56,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
onCreateGroup,
isCreating = false,
}) => {
const { t } = useI18n();
const [groupName, setGroupName] = React.useState('');
const [prompt, setPrompt] = React.useState('');
const [selectedModels, setSelectedModels] = React.useState<ModelSelectionWithId[]>([]);
@@ -166,7 +168,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > MAX_FILE_SIZE) {
toast.error(`File "${file.name}" is too large (max 10MB)`);
toast.error(t('agentManager.empty.toast.fileTooLarge', { fileName: file.name }));
continue;
}
@@ -190,12 +192,16 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
attachedCount++;
} catch (error) {
console.error('File attach failed', error);
toast.error(`Failed to attach "${file.name}"`);
toast.error(t('agentManager.empty.toast.failedToAttach', { fileName: file.name }));
}
}
if (attachedCount > 0) {
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
toast.success(
attachedCount === 1
? t('agentManager.empty.toast.attachedSingle', { count: attachedCount })
: t('agentManager.empty.toast.attachedPlural', { count: attachedCount })
);
}
if (fileInputRef.current) {
@@ -372,7 +378,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
setMentionQuery('');
} catch (error) {
console.error('Failed to create agent group:', error);
toast.error('Failed to create agent group');
toast.error(t('agentManager.empty.toast.failedToCreateGroup'));
} finally {
setIsSubmitting(false);
}
@@ -415,17 +421,17 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
{/* Group Name Input */}
<div className="space-y-1.5">
<label htmlFor="group-name" className="typography-ui-label font-medium text-foreground">
Group Name
{t('agentManager.empty.groupName.label')}
</label>
<Input
id="group-name"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
placeholder="e.g. feature-auth, bugfix-login"
placeholder={t('agentManager.empty.groupName.placeholder')}
className="typography-body"
/>
<p className="typography-micro text-muted-foreground">
Used for worktree directory and branch naming
{t('agentManager.empty.groupName.description')}
</p>
</div>
@@ -433,7 +439,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
<div className="space-y-1.5">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-1.5">
<RiGitBranchLine className="h-4 w-4 text-muted-foreground" />
Base Branch
{t('agentManager.empty.baseBranch.label')}
</label>
<BranchSelector
directory={currentDirectory}
@@ -441,7 +447,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
onChange={setBaseBranch}
/>
<p className="typography-micro text-muted-foreground">
Creates new branches from <code className="font-mono text-xs">{baseBranch}</code>
{t('agentManager.empty.baseBranch.description', { branch: baseBranch })}
</p>
</div>
@@ -449,10 +455,10 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
<Collapsible open={isSetupCommandsOpen} onOpenChange={setIsSetupCommandsOpen}>
<CollapsibleTrigger className="w-full flex items-center justify-between py-1 hover:bg-[var(--interactive-hover)] rounded-md px-1 -mx-1 transition-colors">
<p className="typography-ui-label font-medium text-foreground">
Setup commands
{t('agentManager.empty.setupCommands.label')}
{setupCommands.filter(cmd => cmd.trim()).length > 0 && (
<span className="font-normal text-muted-foreground/70">
{' '}({setupCommands.filter(cmd => cmd.trim()).length} configured)
{' '}({t('agentManager.empty.setupCommands.configured', { count: setupCommands.filter(cmd => cmd.trim()).length })})
</span>
)}
</p>
@@ -464,10 +470,10 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
<CollapsibleContent>
<div className="pt-2 space-y-2">
<p className="typography-micro text-muted-foreground/70">
Commands run in each new worktree. Use <code className="font-mono text-xs">$ROOT_PROJECT_PATH</code> for project root.
{t('agentManager.empty.setupCommands.description')}
</p>
{isLoadingSetupCommands ? (
<p className="typography-meta text-muted-foreground/70">Loading...</p>
<p className="typography-meta text-muted-foreground/70">{t('agentManager.empty.setupCommands.loading')}</p>
) : (
<div className="space-y-1.5">
{setupCommands.map((command, index) => (
@@ -479,7 +485,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
newCommands[index] = e.target.value;
setSetupCommands(newCommands);
}}
placeholder="e.g., bun install"
placeholder={t('agentManager.empty.setupCommands.commandPlaceholder')}
className="h-8 flex-1 font-mono text-xs"
/>
<button
@@ -489,7 +495,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
setSetupCommands(newCommands);
}}
className="flex-shrink-0 flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Remove command"
aria-label={t('agentManager.empty.setupCommands.removeCommandAria')}
>
<RiCloseLine className="h-4 w-4" />
</button>
@@ -501,7 +507,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
className="flex items-center gap-1.5 typography-meta text-muted-foreground hover:text-foreground transition-colors"
>
<RiAddLine className="h-3.5 w-3.5" />
Add command
{t('agentManager.empty.setupCommands.addCommand')}
</button>
</div>
)}
@@ -512,21 +518,21 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
{/* Agent Selection */}
<div className="space-y-1.5">
<label className="typography-ui-label font-medium text-foreground">
Agent
{t('agentManager.empty.agent.label')}
</label>
<AgentSelector
value={selectedAgent}
onChange={setSelectedAgent}
/>
<p className="typography-micro text-muted-foreground">
Defaults to your configured default agent
{t('agentManager.empty.agent.description')}
</p>
</div>
{/* Model Selection */}
<div className="space-y-1.5">
<label className="typography-ui-label font-medium text-foreground">
Models
{t('agentManager.empty.models.label')}
</label>
<ModelMultiSelect
selectedModels={selectedModels}
@@ -534,7 +540,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
onRemove={handleRemoveModel}
onUpdate={handleUpdateModel}
minModels={1}
addButtonLabel="Add model"
addButtonLabel={t('agentManager.empty.models.addModel')}
maxModels={5}
/>
</div>
@@ -542,7 +548,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
{/* Chat Input Style Prompt */}
<div className="space-y-1.5">
<label htmlFor="prompt" className="typography-ui-label font-medium text-foreground">
Prompt
{t('agentManager.empty.prompt.label')}
</label>
<div className="relative">
<div
@@ -561,7 +567,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
updateAutocompleteState(nextPrompt, cursorPosition);
}}
onKeyDown={handleKeyDown}
placeholder="Ask anything..."
placeholder={t('agentManager.empty.prompt.placeholder')}
className="min-h-[100px] max-h-[300px] resize-none border-0 bg-transparent dark:bg-transparent px-4 py-3 typography-markdown focus-visible:ring-0 focus-visible:ring-offset-0"
/>
@@ -609,7 +615,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:text-foreground transition-colors"
aria-label="Add attachment"
aria-label={t('agentManager.empty.prompt.addAttachmentAria')}
>
<RiAddCircleLine className="h-[18px] w-[18px]" />
</button>
@@ -618,7 +624,9 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
{/* Right Controls - Model Count */}
<div className="flex items-center gap-2">
<span className="typography-meta text-muted-foreground">
{selectedModels.length} model{selectedModels.length !== 1 ? 's' : ''} selected
{selectedModels.length === 1
? t('agentManager.empty.models.selectedSingle', { count: selectedModels.length })
: t('agentManager.empty.models.selectedPlural', { count: selectedModels.length })}
</span>
</div>
{/* Submit Button */}
@@ -631,7 +639,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
? 'text-primary hover:text-primary'
: 'opacity-30'
)}
aria-label="Start Agent Group"
aria-label={t('agentManager.empty.actions.startAgentGroupAria')}
>
{isSubmittingOrCreating ? (
<RiHourglassFill className="h-[18px] w-[18px] animate-spin" />
@@ -28,8 +28,9 @@ import {
import { cn } from '@/lib/utils';
import { useAgentGroupsStore, type AgentGroup } from '@/stores/useAgentGroupsStore';
import { useAllSessionStatuses } from '@/sync/sync-context';
import { useI18n } from '@/lib/i18n';
const formatRelativeTime = (timestamp: number): string => {
const formatRelativeTime = (timestamp: number): { unit: 'now' | 'minutes' | 'hours' | 'days'; count?: number } => {
const now = Date.now();
const diff = now - timestamp;
@@ -37,10 +38,10 @@ const formatRelativeTime = (timestamp: number): string => {
const hours = Math.floor(diff / (60 * 60 * 1000));
const days = Math.floor(diff / (24 * 60 * 60 * 1000));
if (minutes < 1) return 'now';
if (minutes < 60) return `${minutes}m`;
if (hours < 24) return `${hours}h`;
return `${days}d`;
if (minutes < 1) return { unit: 'now' };
if (minutes < 60) return { unit: 'minutes', count: minutes };
if (hours < 24) return { unit: 'hours', count: hours };
return { unit: 'days', count: days };
};
interface AgentGroupItemProps {
@@ -51,6 +52,7 @@ interface AgentGroupItemProps {
}
const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBusy, onSelect }) => {
const { t } = useI18n();
const [menuOpen, setMenuOpen] = React.useState(false);
const [confirmOpen, setConfirmOpen] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false);
@@ -59,16 +61,18 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBu
const handleDeleteGroup = React.useCallback(async () => {
if (isDeleting) return;
setIsDeleting(true);
toast.info(`Deleting "${group.name}"...`);
toast.info(t('agentManager.sidebar.toast.deletingGroup', { group: group.name }));
const { failedIds, failedWorktreePaths } = await deleteGroupSessions(group.sessions, { removeWorktrees: true });
if (failedIds.length === 0 && failedWorktreePaths.length === 0) {
toast.success(`Deleted "${group.name}"`);
toast.success(t('agentManager.sidebar.toast.deletedGroup', { group: group.name }));
} else {
toast.error(`Failed to fully delete "${group.name}"`);
toast.error(t('agentManager.sidebar.toast.failedToDeleteGroup', { group: group.name }));
}
setIsDeleting(false);
setConfirmOpen(false);
}, [deleteGroupSessions, group.name, group.sessions, isDeleting]);
}, [deleteGroupSessions, group.name, group.sessions, isDeleting, t]);
const relativeTime = formatRelativeTime(group.lastActive);
return (
<>
@@ -91,12 +95,20 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBu
{isBusy && <RiLoader4Line className="h-3 w-3 animate-spin text-amber-500 flex-shrink-0" />}
</div>
<div className="flex items-center gap-2">
<span className="typography-micro text-muted-foreground/60 flex items-center gap-1">
<RiGitBranchLine className="h-3 w-3" />
{group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''}
</span>
<span className="typography-micro text-muted-foreground/60 flex items-center gap-1">
<RiGitBranchLine className="h-3 w-3" />
{group.sessionCount === 1
? t('agentManager.sidebar.item.modelCountSingle', { count: group.sessionCount })
: t('agentManager.sidebar.item.modelCountPlural', { count: group.sessionCount })}
</span>
<span className="typography-micro text-muted-foreground/60">
{formatRelativeTime(group.lastActive)}
{relativeTime.unit === 'now'
? t('agentManager.sidebar.relativeTime.now')
: relativeTime.unit === 'minutes'
? t('agentManager.sidebar.relativeTime.minutes', { count: relativeTime.count ?? 0 })
: relativeTime.unit === 'hours'
? t('agentManager.sidebar.relativeTime.hours', { count: relativeTime.count ?? 0 })
: t('agentManager.sidebar.relativeTime.days', { count: relativeTime.count ?? 0 })}
</span>
</div>
</button>
@@ -111,7 +123,7 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBu
'opacity-0 group-hover:opacity-100',
menuOpen && 'opacity-100',
)}
aria-label="Group menu"
aria-label={t('agentManager.sidebar.item.groupMenuAria')}
onClick={(e) => e.stopPropagation()}
>
<RiMore2Line className="h-3.5 w-3.5" />
@@ -126,7 +138,7 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBu
setConfirmOpen(true);
}}
>
Delete
{t('agentManager.sidebar.item.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -137,17 +149,17 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBu
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete agent group</DialogTitle>
<DialogTitle>{t('agentManager.sidebar.dialog.deleteGroupTitle')}</DialogTitle>
<DialogDescription>
Delete <span className="text-foreground font-medium">{group.name}</span>? This removes all worktrees and sessions in this group.
{t('agentManager.sidebar.dialog.deleteGroupDescription', { group: group.name })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)} disabled={isDeleting}>
Cancel
{t('agentManager.sidebar.dialog.cancel')}
</Button>
<Button variant="destructive" onClick={() => void handleDeleteGroup()} disabled={isDeleting}>
{isDeleting ? 'Deleting' : 'Delete'}
{isDeleting ? t('agentManager.sidebar.dialog.deleting') : t('agentManager.sidebar.dialog.delete')}
</Button>
</DialogFooter>
</DialogContent>
@@ -171,6 +183,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
onGroupSelect,
onNewAgent,
}) => {
const { t } = useI18n();
const [searchQuery, setSearchQuery] = React.useState('');
const [showAll, setShowAll] = React.useState(false);
const isLoading = useAgentGroupsStore((s) => s.isLoading);
@@ -209,7 +222,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search Agent Groups..."
placeholder={t('agentManager.sidebar.search.placeholder')}
className="pl-8 h-8 rounded-lg border-border/40 bg-background/50 typography-meta"
/>
</div>
@@ -223,7 +236,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
onClick={onNewAgent}
>
<RiAddLine className="h-4 w-4" />
<span className="typography-ui-label">New Agent Group</span>
<span className="typography-ui-label">{t('agentManager.sidebar.actions.newAgentGroup')}</span>
</Button>
</div>
@@ -231,11 +244,11 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
<div className="px-2.5 py-1.5 flex items-center gap-1">
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
<span className="typography-micro font-medium text-muted-foreground uppercase tracking-wider">
Agent Groups
{t('agentManager.sidebar.section.agentGroups')}
</span>
{isLoading && (
<span className="typography-micro text-muted-foreground/50 ml-auto">
Loading...
{t('agentManager.sidebar.state.loading')}
</span>
)}
</div>
@@ -261,7 +274,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
onClick={() => setShowAll(true)}
className="mt-1 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left typography-micro text-muted-foreground/70 hover:text-foreground hover:underline"
>
... More ({remainingCount})
{t('agentManager.sidebar.actions.more', { count: remainingCount })}
</button>
)}
@@ -271,18 +284,18 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
onClick={() => setShowAll(false)}
className="mt-1 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left typography-micro text-muted-foreground/70 hover:text-foreground hover:underline"
>
Show less
{t('agentManager.sidebar.actions.showLess')}
</button>
)}
{!isLoading && filteredGroups.length === 0 && (
<div className="py-4 text-center">
<p className="typography-meta text-muted-foreground">
{searchQuery.trim() ? 'No groups found' : 'No agent groups yet'}
{searchQuery.trim() ? t('agentManager.sidebar.state.noGroupsFound') : t('agentManager.sidebar.state.noGroupsYet')}
</p>
{!searchQuery.trim() && (
<p className="typography-micro text-muted-foreground/60 mt-1">
Create a new agent group to get started
{t('agentManager.sidebar.state.createToGetStarted')}
</p>
)}
</div>
@@ -2,6 +2,7 @@ import React from 'react';
import { RiArrowDownLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
interface AIHighlightsBoxProps {
highlights: string[];
@@ -12,6 +13,7 @@ export const AIHighlightsBox: React.FC<AIHighlightsBoxProps> = ({
highlights,
onInsert,
}) => {
const { t } = useI18n();
if (highlights.length === 0) {
return null;
}
@@ -23,7 +25,7 @@ export const AIHighlightsBox: React.FC<AIHighlightsBoxProps> = ({
return (
<div className="space-y-2 rounded-xl border border-border/60 bg-transparent px-3 py-2">
<div className="flex items-center justify-between gap-2">
<p className="typography-micro text-muted-foreground">AI highlights</p>
<p className="typography-micro text-muted-foreground">{t('gitView.commit.aiHighlights.title')}</p>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
@@ -31,13 +33,13 @@ export const AIHighlightsBox: React.FC<AIHighlightsBoxProps> = ({
size="icon"
className="size-6"
onClick={handleInsert}
aria-label="Insert highlights into commit message"
aria-label={t('gitView.commit.aiHighlights.insertAria')}
>
<RiArrowDownLine className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>
Append highlights to commit message
{t('gitView.commit.aiHighlights.insertTooltip')}
</TooltipContent>
</Tooltip>
</div>
@@ -32,6 +32,7 @@ import {
} from '@/components/ui/command';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
type OperationType = 'merge' | 'rebase';
@@ -66,6 +67,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
onOperationComplete,
mode = 'dialog',
}) => {
const { t } = useI18n();
const [dialogOpen, setDialogOpen] = React.useState(false);
const [operation, setOperation] = React.useState<OperationType>('merge');
const [selectedBranch, setSelectedBranch] = React.useState<string | null>(null);
@@ -75,7 +77,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
const logContainerRef = React.useRef<HTMLDivElement>(null);
const isDisabled = disabled || isOperating;
const targetBranchLabel = currentBranch || 'current branch';
const targetBranchLabel = currentBranch || t('gitView.branch.currentBranchFallback');
// Check if operation completed (all logs are done or error)
const operationCompleted = operationLogs.length > 0 &&
@@ -199,13 +201,13 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
mode === 'dialog' ? (
<DialogFooter>
<Button variant="default" size="sm" onClick={handleClose}>
{hasError ? 'Close' : 'Done'}
{hasError ? t('gitView.common.close') : t('gitView.common.done')}
</Button>
</DialogFooter>
) : (
<div className="flex justify-end">
<Button variant="default" size="sm" onClick={handleClose}>
{hasError ? 'Close' : 'Done'}
{hasError ? t('gitView.common.close') : t('gitView.common.done')}
</Button>
</div>
)
@@ -217,7 +219,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
<div className="space-y-4">
{/* Operation Selection */}
<div className="space-y-3">
<p className="typography-meta text-muted-foreground">Operation</p>
<p className="typography-meta text-muted-foreground">{t('gitView.branch.operation')}</p>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
@@ -239,11 +241,11 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
operation === 'merge' ? 'text-foreground' : 'text-muted-foreground'
)}
>
Merge
{t('gitView.operation.merge')}
</span>
</div>
<p className="typography-micro text-muted-foreground">
Combines branches with a merge commit and preserves history.
{t('gitView.branch.mergeDescription')}
</p>
</button>
@@ -267,11 +269,11 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
operation === 'rebase' ? 'text-foreground' : 'text-muted-foreground'
)}
>
Rebase
{t('gitView.operation.rebase')}
</span>
</div>
<p className="typography-micro text-muted-foreground">
Moves your commits to be on top of another branch. Creates linear history.
{t('gitView.branch.rebaseDescription')}
</p>
</button>
</div>
@@ -280,13 +282,15 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
{/* Branch Selection */}
<div className="flex flex-col gap-3">
<p className="typography-meta text-muted-foreground">
{operation === 'merge' ? `Branch to merge into ${targetBranchLabel}` : 'Branch to rebase onto'}
{operation === 'merge'
? t('gitView.branch.branchToMergeInto', { branch: targetBranchLabel })
: t('gitView.branch.branchToRebaseOnto')}
</p>
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen} modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="lg" className="w-full justify-between">
<span className={cn('truncate', !selectedBranch && 'text-muted-foreground')}>
{selectedBranch || 'Select a branch...'}
{selectedBranch || t('gitView.branch.selectBranch')}
</span>
<RiArrowDownSLine className="size-4 opacity-60 shrink-0" />
</Button>
@@ -299,15 +303,15 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
<Command className="h-full min-h-0">
<CommandInput
ref={searchInputRef}
placeholder="Search branches..."
placeholder={t('gitView.branch.searchPlaceholder')}
value={branchSearch}
onValueChange={setBranchSearch}
/>
<CommandList className="h-full min-h-0" disableHorizontal>
<CommandEmpty>No branches found.</CommandEmpty>
<CommandEmpty>{t('gitView.branch.empty')}</CommandEmpty>
{filteredLocal.length > 0 && (
<CommandGroup heading="Local branches">
<CommandGroup heading={t('gitView.branch.localBranches')}>
{filteredLocal.map((branch) => (
<CommandItem key={`local-${branch}`} onSelect={() => handleSelectBranch(branch)}>
<span className="typography-ui-label text-foreground truncate">{branch}</span>
@@ -319,7 +323,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
{filteredLocal.length > 0 && filteredRemote.length > 0 ? <CommandSeparator /> : null}
{filteredRemote.length > 0 && (
<CommandGroup heading="Remote branches">
<CommandGroup heading={t('gitView.branch.remoteBranches')}>
{filteredRemote.map((branch) => (
<CommandItem key={`remote-${branch}`} onSelect={() => handleSelectBranch(branch)}>
<span className="typography-ui-label text-foreground truncate">{branch}</span>
@@ -339,13 +343,11 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
<p className="typography-meta text-muted-foreground">
{operation === 'merge' ? (
<>
This will merge <span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
<span className="font-mono text-foreground">{targetBranchLabel}</span>
{t('gitView.branch.summaryMergePrefix')} <span className="font-mono text-foreground">{selectedBranch}</span> {t('gitView.branch.summaryMergeInfix')} <span className="font-mono text-foreground">{targetBranchLabel}</span>
</>
) : (
<>
This will rebase <span className="font-mono text-foreground">{targetBranchLabel}</span> onto{' '}
<span className="font-mono text-foreground">{selectedBranch}</span>
{t('gitView.branch.summaryRebasePrefix')} <span className="font-mono text-foreground">{targetBranchLabel}</span> {t('gitView.branch.summaryRebaseInfix')} <span className="font-mono text-foreground">{selectedBranch}</span>
</>
)}
</p>
@@ -355,7 +357,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
{mode === 'dialog' ? (
<DialogFooter className="gap-2 pt-1">
<Button variant="ghost" size="sm" onClick={handleCancel}>
Cancel
{t('gitView.common.cancel')}
</Button>
<Button
variant="default"
@@ -367,12 +369,12 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
{operation === 'merge' ? (
<>
<RiGitMergeLine className="size-4" />
Merge
{t('gitView.operation.merge')}
</>
) : (
<>
<RiGitBranchLine className="size-4" />
Rebase
{t('gitView.operation.rebase')}
</>
)}
</Button>
@@ -380,11 +382,11 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
) : (
<div className="flex items-center gap-2 pt-1">
<Button variant="destructive" size="sm" onClick={handleCancel} disabled={isDisabled}>
Reset
{t('gitView.common.reset')}
</Button>
<div className="flex-1" />
<Button variant="default" size="sm" onClick={handleConfirm} disabled={isDisabled || !selectedBranch}>
{operation === 'merge' ? 'Merge' : 'Rebase'}
{operation === 'merge' ? t('gitView.operation.merge') : t('gitView.operation.rebase')}
</Button>
</div>
)}
@@ -398,9 +400,9 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
<section className="border-0 bg-transparent rounded-none">
<header className="border-b border-border/40 px-0 py-3">
<div className="space-y-1">
<div className="typography-ui-header font-semibold text-foreground">Update branch</div>
<div className="typography-ui-header font-semibold text-foreground">{t('gitView.branch.updateTitle')}</div>
<div className="typography-micro text-muted-foreground">
Bring changes from another branch into{' '}
{t('gitView.branch.updateDescriptionPrefix')}{' '}
<span className="font-mono text-foreground">{targetBranchLabel}</span>.
</div>
</div>
@@ -426,11 +428,11 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
) : (
<RiGitMergeLine className="size-4" />
)}
<span>Merge/Rebase</span>
<span>{t('gitView.branch.mergeRebase')}</span>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>
Merge or rebase changes from another branch.
{t('gitView.branch.mergeRebaseTooltip')}
</TooltipContent>
</Tooltip>
@@ -443,17 +445,17 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
}}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Update Branch</DialogTitle>
<DialogTitle>{t('gitView.branch.updateTitle')}</DialogTitle>
<DialogDescription>
{isOperating ? (
operationCompleted ? (
hasError ? 'Operation failed' : 'Operation completed'
hasError ? t('gitView.branch.operationFailed') : t('gitView.branch.operationCompleted')
) : (
`${operation === 'merge' ? 'Merging' : 'Rebasing'} in progress...`
operation === 'merge' ? t('gitView.branch.mergingInProgress') : t('gitView.branch.rebasingInProgress')
)
) : (
<>
Choose how to bring changes from another branch into{' '}
{t('gitView.branch.dialogDescriptionPrefix')}{' '}
<span className="font-mono text-foreground">{targetBranchLabel}</span>
.
</>
@@ -24,6 +24,7 @@ import {
} from '@/components/ui/command';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { GitRemote } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
interface BranchInfo {
ahead?: number;
@@ -66,6 +67,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
disabled = false,
tooltipDelayMs = 1000,
}) => {
const { t } = useI18n();
const [isOpen, setIsOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const [showCreate, setShowCreate] = React.useState(false);
@@ -179,21 +181,21 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
>
<RiGitBranchLine className="size-4 text-primary" />
<span className="min-w-0 truncate font-medium text-left">
{currentBranch || 'Detached HEAD'}
{currentBranch || t('gitView.branch.detachedHead')}
</span>
<RiArrowDownSLine className="size-4 opacity-60" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent sideOffset={8}>
Current branch
{t('gitView.branch.currentBranchTooltip')}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="w-72 p-0 max-h-[60vh] flex flex-col">
<Command className="h-full min-h-0">
<CommandInput
placeholder="Search branches..."
placeholder={t('gitView.branch.searchPlaceholder')}
value={search}
onValueChange={setSearch}
/>
@@ -201,7 +203,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
scrollbarClassName="overlay-scrollbar--flush overlay-scrollbar--dense overlay-scrollbar--zero"
disableHorizontal
>
<CommandEmpty>No branches found.</CommandEmpty>
<CommandEmpty>{t('gitView.branch.empty')}</CommandEmpty>
<CommandGroup>
{showRemoteSelect ? (
@@ -217,7 +219,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
<RiArrowLeftLine className="size-4" />
</button>
<span className="typography-meta text-muted-foreground">
Push <span className="text-foreground font-medium">{sanitizedNewBranch}</span> to:
{t('gitView.branch.pushToPrefix')} <span className="text-foreground font-medium">{sanitizedNewBranch}</span> {t('gitView.branch.pushToSuffix')}
</span>
</div>
<div className="flex flex-col gap-1">
@@ -245,13 +247,13 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
) : !showCreate ? (
<CommandItem onSelect={handleShowCreate}>
<RiAddLine className="size-4" />
<span>Create new branch...</span>
<span>{t('gitView.branch.create')}</span>
</CommandItem>
) : (
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg">
<input
ref={createInputRef}
placeholder="New branch name"
placeholder={t('gitView.branch.newBranchPlaceholder')}
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
onClick={(e) => e.stopPropagation()}
@@ -293,7 +295,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
<CommandSeparator />
<CommandGroup heading="Local branches">
<CommandGroup heading={t('gitView.branch.localBranches')}>
{filteredLocal.map((branch) => (
<CommandItem
key={`local-${branch}`}
@@ -311,14 +313,14 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
)}
</span>
{currentBranch === branch && (
<span className="typography-micro text-primary">Current</span>
<span className="typography-micro text-primary">{t('gitView.branch.currentBadge')}</span>
)}
</CommandItem>
))}
{filteredLocal.length === 0 && (
<CommandItem disabled className="justify-center">
<span className="typography-meta text-muted-foreground">
No local branches
{t('gitView.branch.noLocalBranches')}
</span>
</CommandItem>
)}
@@ -326,7 +328,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
<CommandSeparator />
<CommandGroup heading="Remote branches">
<CommandGroup heading={t('gitView.branch.remoteBranches')}>
{filteredRemote.map((branch) => (
<CommandItem
key={`remote-${branch}`}
@@ -338,7 +340,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
{filteredRemote.length === 0 && (
<CommandItem disabled className="justify-center">
<span className="typography-meta text-muted-foreground">
No remote branches
{t('gitView.branch.noRemoteBranches')}
</span>
</CommandItem>
)}
@@ -7,6 +7,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { Checkbox } from '@/components/ui/checkbox';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import type { GitStatus } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
type ChangeDescriptor = {
code: string;
@@ -64,6 +65,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
indentPx = 0,
}) {
const descriptor = useMemo(() => describeChange(file), [file]);
const { t } = useI18n();
const indicatorLabel = descriptor.description;
const insertions = stats?.insertions ?? 0;
const deletions = stats?.deletions ?? 0;
@@ -104,7 +106,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
size="sm"
checked={checked}
onChange={() => onToggle()}
ariaLabel={`Select ${file.path}`}
ariaLabel={t('gitView.changes.selectFileAria', { path: file.path })}
/>
</div>
<span
@@ -155,7 +157,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
onClick={handleRevertClick}
disabled={isReverting}
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Revert changes for ${file.path}`}
aria-label={t('gitView.changes.revertFileAria', { path: file.path })}
>
{isReverting ? (
<RiLoader4Line className="size-3.5 animate-spin" />
@@ -164,7 +166,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
)}
</button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Revert changes</TooltipContent>
<TooltipContent sideOffset={8}>{t('gitView.changes.revertFileTooltip')}</TooltipContent>
</Tooltip>
</div>
);
@@ -17,6 +17,7 @@ import { ChangeRow } from './ChangeRow';
import type { GitStatus } from '@/lib/api/types';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
interface ChangesSectionProps {
changeEntries: GitStatus['files'];
@@ -183,6 +184,7 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
maxListHeightClassName,
onVisiblePathsChange,
}) => {
const { t } = useI18n();
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const gitChangesViewMode = useUIStore((state) => state.gitChangesViewMode);
const isTreeView = gitChangesViewMode === 'tree';
@@ -386,7 +388,9 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
type="button"
onClick={() => toggleDirectoryExpanded(directory.path)}
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={isExpanded ? `Collapse ${directory.path}` : `Expand ${directory.path}`}
aria-label={isExpanded
? t('gitView.changes.collapseDirectoryAria', { path: directory.path })
: t('gitView.changes.expandDirectoryAria', { path: directory.path })}
>
{isExpanded ? <RiArrowDownSLine className="size-4" /> : <RiArrowRightSLine className="size-4" />}
</button>
@@ -395,7 +399,7 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
type="button"
role="checkbox"
aria-checked={selectionState === 'partial' ? 'mixed' : selectionState === 'all'}
aria-label={`Toggle selection for directory ${directory.path}`}
aria-label={t('gitView.changes.toggleDirectorySelectionAria', { path: directory.path })}
onClick={() => toggleDirectorySelection(directory)}
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
@@ -443,7 +447,7 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
<section className={containerClassName}>
<header className={headerClassName}>
<div className="flex min-w-0 items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Changes</h3>
<h3 className="typography-ui-header font-semibold text-foreground">{t('gitView.changes.title')}</h3>
{totalCount > 0 ? (
<div
className={cn(
@@ -457,7 +461,7 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
indeterminate={isPartiallySelected}
disabled={isRevertingAll}
onChange={() => (areAllSelected ? onClearSelection() : onSelectAll())}
ariaLabel={areAllSelected ? 'Clear file selection' : 'Select all files'}
ariaLabel={areAllSelected ? t('gitView.changes.clearSelectionAria') : t('gitView.changes.selectAllAria')}
/>
<span className="typography-meta text-muted-foreground">{selectedCount}/{totalCount}</span>
</div>
@@ -471,7 +475,7 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
onClick={() => setConfirmRevertAllOpen(true)}
disabled={isRevertingAll}
>
Revert all
{t('gitView.changes.revertAll')}
</Button>
) : null}
</div>
@@ -513,7 +517,7 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
})}
</div>
) : (
<div role="list" aria-label="Changed files">
<div role="list" aria-label={t('gitView.changes.changedFilesAria')}>
{rowItems.map((item, index) => (
<div
key={isTreeView ? (item as FlattenedTreeRow).key : `file:${(item as GitStatus['files'][number]).path}`}
@@ -535,17 +539,19 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
<Dialog open={confirmRevertAllOpen} onOpenChange={(open) => { if (!isRevertingAll) setConfirmRevertAllOpen(open); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Revert all changes?</DialogTitle>
<DialogTitle>{t('gitView.changes.revertAllDialogTitle')}</DialogTitle>
<DialogDescription>
This will discard local changes for {totalCount} file{totalCount === 1 ? '' : 's'} in the list.
{totalCount === 1
? t('gitView.changes.revertAllDescriptionSingle', { count: totalCount })
: t('gitView.changes.revertAllDescriptionPlural', { count: totalCount })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setConfirmRevertAllOpen(false)} disabled={isRevertingAll}>
Cancel
{t('gitView.common.cancel')}
</Button>
<Button variant="destructive" size="sm" onClick={() => void handleConfirmRevertAll()} disabled={isRevertingAll}>
{isRevertingAll ? 'Reverting...' : 'Revert all'}
{isRevertingAll ? t('gitView.changes.reverting') : t('gitView.changes.revertAll')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,6 +1,7 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
interface CommitInputProps {
value: string;
@@ -17,11 +18,12 @@ const MAX_HEIGHT = 200;
export const CommitInput: React.FC<CommitInputProps> = ({
value,
onChange,
placeholder = 'Commit message',
placeholder,
disabled = false,
hasTouchInput = false,
isMobile = false,
}) => {
const { t } = useI18n();
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled);
@@ -56,7 +58,7 @@ export const CommitInput: React.FC<CommitInputProps> = ({
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
placeholder={placeholder ?? t('gitView.commit.messagePlaceholder')}
rows={1}
disabled={disabled}
autoCorrect={hasTouchInput ? 'on' : 'off'}
@@ -10,6 +10,7 @@ import { CommitInput } from './CommitInput';
import { AIHighlightsBox } from './AIHighlightsBox';
import { useDeviceInfo } from '@/lib/device';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
type CommitAction = 'commit' | 'commitAndPush' | null;
@@ -44,6 +45,7 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
gitmojiEnabled,
onOpenGitmojiPicker,
}) => {
const { t } = useI18n();
const hasSelectedFiles = selectedCount > 0;
const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null;
const { isMobile, hasTouchInput } = useDeviceInfo();
@@ -55,13 +57,13 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
return (
<section className={containerClassName}>
<div className={headerClassName}>
<h3 className="typography-ui-header font-semibold text-foreground">Commit</h3>
<h3 className="typography-ui-header font-semibold text-foreground">{t('gitView.commit.title')}</h3>
</div>
<div className={contentClassName}>
{!hasSelectedFiles ? (
<p className="typography-meta text-muted-foreground">
Select files in Changes to enable commit.
{t('gitView.commit.selectFilesHint')}
</p>
) : null}
@@ -73,7 +75,7 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
<CommitInput
value={commitMessage}
onChange={onCommitMessageChange}
placeholder="Commit message"
placeholder={t('gitView.commit.messagePlaceholder')}
disabled={commitAction !== null}
hasTouchInput={hasTouchInput}
isMobile={isMobile}
@@ -88,7 +90,7 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
type="button"
>
<RiEmotionHappyLine className="size-4" />
Add gitmoji
{t('gitView.commit.addGitmoji')}
</Button>
)}
@@ -104,7 +106,7 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
isBusy
}
type="button"
aria-label="Generate"
aria-label={t('gitView.commit.generateAria')}
className="commit-actions__btn"
>
{isGeneratingMessage ? (
@@ -112,7 +114,7 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
) : (
<RiAiGenerate2 className="size-4 text-primary" />
)}
<span className="commit-actions__label">Generate</span>
<span className="commit-actions__label">{t('gitView.commit.generate')}</span>
</Button>
<div className="flex-1" />
@@ -123,17 +125,17 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
onClick={onCommit}
disabled={!canCommit || isGeneratingMessage}
className="commit-actions__btn whitespace-nowrap"
aria-label="Commit"
aria-label={t('gitView.commit.commitAria')}
>
{commitAction === 'commit' ? (
<>
<RiLoader4Line className="size-4 animate-spin" />
<span className="commit-actions__label">Committing...</span>
<span className="commit-actions__label">{t('gitView.commit.committing')}</span>
</>
) : (
<>
<RiGitCommitLine className="size-4" />
<span className="commit-actions__label">Commit</span>
<span className="commit-actions__label">{t('gitView.commit.commit')}</span>
</>
)}
</Button>
@@ -147,7 +149,7 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
onClick={() => onCommitAndPush()}
disabled={!canCommit || isGeneratingMessage}
className="h-7 w-7 p-0"
aria-label="Push"
aria-label={t('gitView.commit.pushAria')}
>
{commitAction === 'commitAndPush' ? (
<RiLoader4Line className="size-4 animate-spin" />
@@ -157,7 +159,7 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Push</p>
<p>{t('gitView.commit.push')}</p>
</TooltipContent>
</Tooltip>
) : (
@@ -167,17 +169,17 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
onClick={() => onCommitAndPush()}
disabled={!canCommit || isGeneratingMessage}
className="commit-actions__btn"
aria-label="Push"
aria-label={t('gitView.commit.pushAria')}
>
{commitAction === 'commitAndPush' ? (
<>
<RiLoader4Line className="size-4 animate-spin" />
<span className="commit-actions__label">Pushing...</span>
<span className="commit-actions__label">{t('gitView.commit.pushing')}</span>
</>
) : (
<>
<RiArrowUpLine className="size-3.5" />
<span className="commit-actions__label">Push</span>
<span className="commit-actions__label">{t('gitView.commit.push')}</span>
</>
)}
</Button>
@@ -15,6 +15,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { toast } from '@/components/ui';
import { getConflictDetails, type MergeConflictDetails } from '@/lib/gitApi';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { useI18n } from '@/lib/i18n';
interface ConflictDialogProps {
open: boolean;
@@ -35,6 +36,7 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
onAbort,
onClearState,
}) => {
const { t } = useI18n();
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
@@ -58,7 +60,7 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
setConflictDetails(details);
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load conflict details';
const message = err instanceof Error ? err.message : t('gitView.conflict.loadFailed');
setLoadError(message);
})
.finally(() => {
@@ -119,12 +121,12 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
const handleResolveInCurrentSession = async () => {
const context = await buildConflictContext();
if (!context) {
toast.error('No conflict details available');
toast.error(t('gitView.conflict.noDetailsAvailable'));
return;
}
if (!currentSessionId) {
toast.error('No active session', { description: 'Open a chat session first or use "New Session".' });
toast.error(t('gitView.conflict.noActiveSession'), { description: t('gitView.conflict.noActiveSessionDescription') });
return;
}
@@ -143,7 +145,7 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
const handleResolveInNewSession = async () => {
const context = await buildConflictContext();
if (!context) {
toast.error('No conflict details available');
toast.error(t('gitView.conflict.noDetailsAvailable'));
return;
}
@@ -162,7 +164,7 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
onOpenChange(false);
};
const operationLabel = operation === 'merge' ? 'Merge' : 'Rebase';
const operationLabel = operation === 'merge' ? t('gitView.operation.merge') : t('gitView.operation.rebase');
const displayFiles = conflictDetails?.unmergedFiles || conflictFiles;
return (
@@ -172,30 +174,30 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
<DialogHeader>
<div className="flex items-center gap-2">
<RiAlertLine className="size-5 shrink-0 text-[var(--status-warning)]" />
<DialogTitle>{operationLabel} Conflicts Detected</DialogTitle>
<DialogTitle>{t('gitView.conflict.detectedTitle', { operation: operationLabel })}</DialogTitle>
</div>
<DialogDescription>
The {operation} operation resulted in conflicts that need to be resolved.
{t('gitView.conflict.detectedDescription', { operation })}
</DialogDescription>
</DialogHeader>
{isLoading && (
<div className="flex items-center justify-center gap-2 py-4 text-muted-foreground">
<RiLoader4Line className="size-4 animate-spin" />
<span className="typography-meta">Loading conflict details...</span>
<span className="typography-meta">{t('gitView.conflict.loading')}</span>
</div>
)}
{loadError && (
<div className="rounded-lg bg-[var(--status-error-bg)] p-3 text-[var(--status-error)] typography-meta break-words">
Error loading details: {loadError}
{t('gitView.conflict.errorLoadingDetails', { message: loadError })}
</div>
)}
{displayFiles.length > 0 && (
<div className="space-y-2 overflow-hidden">
<div className="flex items-center justify-between">
<p className="typography-meta text-muted-foreground">Conflicted files:</p>
<p className="typography-meta text-muted-foreground">{t('gitView.conflict.conflictedFiles')}</p>
<span className="typography-micro px-1.5 py-0.5 rounded bg-[var(--surface-elevated)] text-muted-foreground">
{displayFiles.length}
</span>
@@ -218,7 +220,7 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
{conflictDetails?.headInfo && (
<div className="space-y-1 overflow-hidden">
<p className="typography-meta text-muted-foreground">HEAD information:</p>
<p className="typography-meta text-muted-foreground">{t('gitView.conflict.headInfo')}</p>
<div className="typography-micro text-foreground font-mono bg-[var(--surface-elevated)] rounded-lg p-3 max-h-24 overflow-y-auto break-words whitespace-pre-wrap">
{conflictDetails.headInfo}
</div>
@@ -239,7 +241,7 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
) : (
<RiAddLine className="size-4" />
)}
Resolve in New Session
{t('gitView.conflict.resolveNewSession')}
</Button>
<Button
variant="outline"
@@ -252,14 +254,14 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
) : (
<RiChat1Line className="size-4" />
)}
Resolve in Current Session
{t('gitView.conflict.resolveCurrentSession')}
</Button>
<div className="flex gap-2 pt-1">
<Button variant="ghost" size="sm" onClick={handleContinueLater} className="flex-1">
Continue Later
{t('gitView.conflict.continueLater')}
</Button>
<Button variant="destructive" size="sm" onClick={handleAbort} className="flex-1">
Abort {operationLabel}
{t('gitView.conflict.abortOperation', { operation: operationLabel })}
</Button>
</div>
</div>
@@ -1,6 +1,7 @@
import React from 'react';
import { RiGitCommitLine, RiArrowDownLine, RiLoader4Line } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/i18n';
interface GitEmptyStateProps {
behind: number;
@@ -13,14 +14,15 @@ export const GitEmptyState: React.FC<GitEmptyStateProps> = ({
onPull,
isPulling,
}) => {
const { t } = useI18n();
return (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center">
<RiGitCommitLine className="size-10 text-muted-foreground/70 mb-4" />
<p className="typography-ui-label font-semibold text-foreground mb-1">
Working tree clean
{t('gitView.empty.cleanTitle')}
</p>
<p className="typography-meta text-muted-foreground mb-4">
All changes have been committed
{t('gitView.empty.cleanDescription')}
</p>
{behind > 0 && (
@@ -34,7 +36,9 @@ export const GitEmptyState: React.FC<GitEmptyStateProps> = ({
) : (
<RiArrowDownLine className="size-4" />
)}
Pull {behind} commit{behind === 1 ? '' : 's'}
{behind === 1
? t('gitView.empty.pullBehindSingle', { count: behind })
: t('gitView.empty.pullBehindPlural', { count: behind })}
</Button>
)}
</div>
@@ -25,6 +25,7 @@ import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
import { SyncActions } from './SyncActions';
import type { GitStatus, GitIdentityProfile, GitRemote } from '@/lib/api/types';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
type SyncAction = 'fetch' | 'pull' | 'push' | null;
@@ -115,6 +116,7 @@ const IdentityDropdown: React.FC<IdentityDropdownProps> = ({
tooltipDelayMs = 1000,
iconOnly = false,
}) => {
const { t } = useI18n();
const isDisabled = isApplying || identities.length === 0;
return (
@@ -140,20 +142,20 @@ const IdentityDropdown: React.FC<IdentityDropdownProps> = ({
)}
{!iconOnly && (
<span className="git-identity-label min-w-0 flex-1 truncate text-left">
{activeProfile?.name || 'No identity'}
{activeProfile?.name || t('gitView.header.noIdentity')}
</span>
)}
<RiArrowDownSLine className="size-4 opacity-60" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Git identity</TooltipContent>
<TooltipContent sideOffset={8}>{t('gitView.header.identityTooltip')}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-64">
{identities.length === 0 ? (
<div className="px-2 py-1.5">
<p className="typography-meta text-muted-foreground">
No profiles available to apply.
{t('gitView.header.noProfiles')}
</p>
</div>
) : (
@@ -210,6 +212,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
isWorktreeMode,
onOpenHistory,
}) => {
const { t } = useI18n();
const isMobile = useUIStore((state) => state.isMobile);
if (!status) {
@@ -232,7 +235,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
<RiHistoryLine className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>History</TooltipContent>
<TooltipContent sideOffset={8}>{t('gitView.history.title')}</TooltipContent>
</Tooltip>
) : null}
</div>
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { GitLogEntry, CommitFileEntry } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
interface HistoryCommitRowProps {
entry: GitLogEntry;
@@ -53,6 +54,7 @@ export const HistoryCommitRow: React.FC<HistoryCommitRowProps> = ({
isLoadingFiles,
onCopyHash,
}) => {
const { t } = useI18n();
return (
<li>
<button
@@ -100,7 +102,7 @@ export const HistoryCommitRow: React.FC<HistoryCommitRowProps> = ({
<RiFileCopyLine className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Copy SHA</TooltipContent>
<TooltipContent sideOffset={8}>{t('gitView.history.copySha')}</TooltipContent>
</Tooltip>
</div>
</div>
@@ -111,10 +113,10 @@ export const HistoryCommitRow: React.FC<HistoryCommitRowProps> = ({
{isLoadingFiles ? (
<div className="flex items-center gap-2 py-2">
<RiLoader4Line className="size-4 animate-spin text-muted-foreground" />
<span className="typography-micro text-muted-foreground">Loading files...</span>
<span className="typography-micro text-muted-foreground">{t('gitView.history.loadingFiles')}</span>
</div>
) : files.length === 0 ? (
<p className="typography-micro text-muted-foreground py-2">No files</p>
<p className="typography-micro text-muted-foreground py-2">{t('gitView.history.noFiles')}</p>
) : (
<ul className="space-y-0.5 py-2">
{files.map((file) => (
@@ -146,7 +148,7 @@ export const HistoryCommitRow: React.FC<HistoryCommitRowProps> = ({
)}
{file.isBinary && (
<span className="typography-micro text-muted-foreground shrink-0">
binary
{t('gitView.history.binary')}
</span>
)}
</li>
@@ -15,11 +15,12 @@ import {
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { HistoryCommitRow } from './HistoryCommitRow';
import type { GitLogEntry, CommitFileEntry } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
const LOG_SIZE_OPTIONS = [
{ label: '25 commits', value: 25 },
{ label: '50 commits', value: 50 },
{ label: '100 commits', value: 100 },
{ labelKey: 'gitView.history.logSize25', value: 25 },
{ labelKey: 'gitView.history.logSize50', value: 50 },
{ labelKey: 'gitView.history.logSize100', value: 100 },
];
interface HistorySectionProps {
@@ -53,6 +54,7 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
showHeader = true,
branchDivider = null,
}) => {
const { t } = useI18n();
const [isOpen, setIsOpen] = React.useState(true);
if (!log) {
@@ -98,7 +100,7 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
{log.all.length === 0 ? (
<div className="flex h-full items-center justify-center p-4">
<p className="typography-ui-label text-muted-foreground">
No commits found
{t('gitView.history.noCommits')}
</p>
</div>
) : hasSplitHistory && branchDivider ? (
@@ -148,7 +150,7 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
className="rounded-xl border border-border/60 bg-background/70 overflow-hidden"
>
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 h-10 hover:bg-transparent">
<h3 className="typography-ui-header font-semibold text-foreground">History</h3>
<h3 className="typography-ui-header font-semibold text-foreground">{t('gitView.history.title')}</h3>
<div className="flex items-center gap-2">
{isOpen && (
<div
@@ -165,12 +167,12 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
className="data-[size=sm]:h-auto h-7 min-h-7 w-auto justify-between px-2 py-0"
disabled={isLogLoading}
>
<SelectValue placeholder="Commits" />
<SelectValue placeholder={t('gitView.history.commitsPlaceholder')} />
</SelectTrigger>
<SelectContent>
{LOG_SIZE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
{t(option.labelKey as never)}
</SelectItem>
))}
</SelectContent>
@@ -9,6 +9,7 @@ import {
} from '@remixicon/react';
import { Button } from '@/components/ui/button';
import type { GitMergeInProgress, GitRebaseInProgress } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
interface InProgressOperationBannerProps {
mergeInProgress: GitMergeInProgress | null | undefined;
@@ -29,6 +30,7 @@ export const InProgressOperationBanner: React.FC<InProgressOperationBannerProps>
hasUnresolvedConflicts = false,
isLoading = false,
}) => {
const { t } = useI18n();
const [processingAction, setProcessingAction] = React.useState<'continue' | 'abort' | null>(null);
// Only show banner if we have actual in-progress operation data
@@ -60,19 +62,19 @@ export const InProgressOperationBanner: React.FC<InProgressOperationBannerProps>
const isProcessing = processingAction !== null;
const operationLabel = operation === 'merge' ? 'Merge' : 'Rebase';
const operationLabel = operation === 'merge' ? t('gitView.operation.merge') : t('gitView.operation.rebase');
const OperationIcon = operation === 'merge' ? RiGitMergeLine : RiGitBranchLine;
// Build description
let description = '';
if (mergeInProgress) {
description = mergeInProgress.message
? `Merging: ${mergeInProgress.message}`
: `Merge in progress (${mergeInProgress.head})`;
? t('gitView.operation.mergingMessage', { message: mergeInProgress.message })
: t('gitView.operation.mergeInProgressWithHead', { head: mergeInProgress.head });
} else if (rebaseInProgress) {
description = rebaseInProgress.headName
? `Rebasing ${rebaseInProgress.headName} onto ${rebaseInProgress.onto}`
: `Rebase in progress`;
? t('gitView.operation.rebasingOnto', { headName: rebaseInProgress.headName, onto: rebaseInProgress.onto || '' })
: t('gitView.operation.rebaseInProgress');
}
return (
@@ -82,7 +84,7 @@ export const InProgressOperationBanner: React.FC<InProgressOperationBannerProps>
<OperationIcon className="size-4 text-[var(--status-warning)] shrink-0" />
<div className="min-w-0">
<p className="typography-label text-[var(--status-warning)]">
{operationLabel} in Progress
{t('gitView.operation.inProgressTitle', { operation: operationLabel })}
</p>
{description && (
<p className="typography-micro text-muted-foreground truncate">
@@ -102,7 +104,7 @@ export const InProgressOperationBanner: React.FC<InProgressOperationBannerProps>
className="gap-1.5"
>
<RiSparklingLine className="size-4" />
Resolve with AI
{t('gitView.operation.resolveWithAi')}
</Button>
)}
@@ -119,7 +121,7 @@ export const InProgressOperationBanner: React.FC<InProgressOperationBannerProps>
) : (
<RiCloseLine className="size-4" />
)}
Abort
{t('gitView.operation.abort')}
</Button>
)}
@@ -136,7 +138,7 @@ export const InProgressOperationBanner: React.FC<InProgressOperationBannerProps>
) : (
<RiCheckLine className="size-4" />
)}
Continue
{t('gitView.operation.continue')}
</Button>
)}
</div>
@@ -144,7 +146,7 @@ export const InProgressOperationBanner: React.FC<InProgressOperationBannerProps>
{hasUnresolvedConflicts && (
<p className="typography-micro text-[var(--status-warning)] mt-2">
Conflicts must be resolved before continuing. Use &quot;Resolve with AI&quot; or resolve manually, then stage changes and click Continue.
{t('gitView.operation.resolveConflictsHint')}
</p>
)}
</div>
@@ -32,6 +32,7 @@ import {
type IntegratePlan,
} from '@/lib/git/integrateWorktreeCommits';
import type { WorktreeMetadata } from '@/types/worktree';
import { useI18n } from '@/lib/i18n';
type IntegrateUiState =
| { kind: 'idle' }
@@ -57,6 +58,7 @@ export const IntegrateCommitsSection: React.FC<{
refreshKey,
onRefresh,
}) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
@@ -240,7 +242,7 @@ export const IntegrateCommitsSection: React.FC<{
// Use current session - set pending input text and synthetic parts
if (!currentSessionId) {
toast.error('No active session', { description: 'Open a chat session first or start a new session.' });
toast.error(t('gitView.integrate.noActiveSession'), { description: t('gitView.integrate.noActiveSessionDescription') });
return;
}
@@ -255,15 +257,17 @@ export const IntegrateCommitsSection: React.FC<{
const handleMove = React.useCallback(async () => {
if (ui.kind !== 'ready') return;
if (ui.plan.commits.length === 0) {
toast.message('No commits to move');
toast.message(t('gitView.integrate.noCommitsToMoveToast'));
return;
}
setUi({ kind: 'running', plan: ui.plan });
try {
const result = await integrateWorktreeCommits(ui.plan);
if (result.kind === 'success') {
toast.success('Commits moved', {
description: `${result.moved} commit${result.moved === 1 ? '' : 's'} into ${ui.plan.targetBranch}`,
toast.success(t('gitView.integrate.commitsMovedToast'), {
description: result.moved === 1
? t('gitView.integrate.commitsMovedDescriptionSingle', { count: result.moved, branch: ui.plan.targetBranch })
: t('gitView.integrate.commitsMovedDescriptionPlural', { count: result.moved, branch: ui.plan.targetBranch }),
});
const next = await computeIntegratePlan(ui.plan);
setUi({ kind: 'ready', plan: next });
@@ -271,7 +275,7 @@ export const IntegrateCommitsSection: React.FC<{
return;
}
if (result.kind === 'conflict') {
toast.error('Cherry-pick conflict', { description: 'Resolve conflicts, then continue.' });
toast.error(t('gitView.integrate.cherryPickConflictToast'), { description: t('gitView.integrate.cherryPickConflictDescription') });
setUi({ kind: 'conflict', state: result.state, details: result.details });
if (conflictStorageKey && typeof window !== 'undefined') {
window.localStorage.setItem(conflictStorageKey, JSON.stringify(result.state));
@@ -279,7 +283,7 @@ export const IntegrateCommitsSection: React.FC<{
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to move commits', { description: message });
toast.error(t('gitView.integrate.failedToMoveToast'), { description: message });
const next = await computeIntegratePlan({ repoRoot, sourceBranch, targetBranch }).catch(() => null);
if (next) setUi({ kind: 'ready', plan: next });
else setUi({ kind: 'idle' });
@@ -290,7 +294,7 @@ export const IntegrateCommitsSection: React.FC<{
if (ui.kind !== 'conflict') return;
try {
await abortIntegrate(ui.state);
toast.message('Cherry-pick aborted');
toast.message(t('gitView.integrate.cherryPickAbortedToast'));
if (conflictStorageKey && typeof window !== 'undefined') {
window.localStorage.removeItem(conflictStorageKey);
}
@@ -306,7 +310,7 @@ export const IntegrateCommitsSection: React.FC<{
try {
const result = await continueIntegrate(ui.state);
if (result.kind === 'success') {
toast.success('Cherry-pick finished');
toast.success(t('gitView.integrate.cherryPickFinishedToast'));
const next = await computeIntegratePlan({ repoRoot, sourceBranch, targetBranch }).catch(() => null);
if (next) setUi({ kind: 'ready', plan: next });
else setUi({ kind: 'idle' });
@@ -324,7 +328,7 @@ export const IntegrateCommitsSection: React.FC<{
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Cherry-pick continue failed', { description: message });
toast.error(t('gitView.integrate.cherryPickContinueFailedToast'), { description: message });
}
}, [ui, repoRoot, sourceBranch, targetBranch, onRefresh, conflictStorageKey]);
@@ -341,9 +345,11 @@ export const IntegrateCommitsSection: React.FC<{
<div className={headerClassName}>
<div className="flex items-center gap-2 min-w-0">
<RiSplitCellsHorizontal className="size-4 text-muted-foreground" />
<h3 className="typography-ui-header font-semibold text-foreground truncate">Re-integrate commits</h3>
<h3 className="typography-ui-header font-semibold text-foreground truncate">{t('gitView.integrate.title')}</h3>
{ui.kind === 'ready' && ui.plan.commits.length > 0 ? (
<span className="typography-meta text-muted-foreground truncate">{ui.plan.commits.length} to move</span>
<span className="typography-meta text-muted-foreground truncate">
{t('gitView.integrate.toMoveCount', { count: ui.plan.commits.length })}
</span>
) : null}
</div>
<div className="flex items-center gap-2">
@@ -356,7 +362,7 @@ export const IntegrateCommitsSection: React.FC<{
<div className={bodyClassName}>
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0">
<div className="typography-ui-label text-foreground">Move commits</div>
<div className="typography-ui-label text-foreground">{t('gitView.integrate.moveCommits')}</div>
<div className="typography-micro text-muted-foreground truncate">
{sourceBranch} {targetBranch}
</div>
@@ -367,7 +373,7 @@ export const IntegrateCommitsSection: React.FC<{
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
Target
{t('gitView.integrate.target')}
<span className="max-w-[160px] truncate font-mono text-xs text-muted-foreground">{targetBranch}</span>
<RiArrowDownSLine className="size-4 opacity-60" />
</Button>
@@ -377,14 +383,14 @@ export const IntegrateCommitsSection: React.FC<{
className="w-72 p-0 max-h-[var(--available-height)] flex flex-col overflow-hidden"
>
<Command className="h-full min-h-0">
<CommandInput ref={searchInputRef} placeholder="Search branches..." />
<CommandInput ref={searchInputRef} placeholder={t('gitView.branch.searchPlaceholder')} />
<CommandList
className="h-full min-h-0"
scrollbarClassName="overlay-scrollbar--flush overlay-scrollbar--dense overlay-scrollbar--zero"
disableHorizontal
>
<CommandEmpty>No branches found.</CommandEmpty>
<CommandGroup heading="Local branches">
<CommandEmpty>{t('gitView.branch.empty')}</CommandEmpty>
<CommandGroup heading={t('gitView.branch.localBranches')}>
{localBranches.map((branch) => (
<CommandItem
key={branch}
@@ -406,28 +412,28 @@ export const IntegrateCommitsSection: React.FC<{
{ui.kind === 'ready' ? (
<Button size="sm" onClick={() => void handleMove()} disabled={!isEligible || ui.plan.commits.length === 0}>
Move
{t('gitView.integrate.move')}
</Button>
) : ui.kind === 'loading' ? (
<Button size="sm" variant="outline" disabled>
Checking
{t('gitView.integrate.checking')}
</Button>
) : ui.kind === 'running' ? (
<Button size="sm" variant="outline" disabled>
Moving
{t('gitView.integrate.moving')}
</Button>
) : null}
</div>
{ui.kind === 'ready' && ui.plan.commits.length === 0 && (
<div className="typography-meta text-muted-foreground">No commits to move.</div>
<div className="typography-meta text-muted-foreground">{t('gitView.integrate.noCommitsToMove')}</div>
)}
{ui.kind === 'ready' && ui.plan.commits.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="typography-meta text-foreground">
Commits to move
{t('gitView.integrate.commitsToMove')}
<span className="text-muted-foreground"> ({ui.plan.commits.length})</span>
</div>
{commitSummaries.length > 0 && ui.plan.commits.length > 5 && (
@@ -436,7 +442,7 @@ export const IntegrateCommitsSection: React.FC<{
onClick={() => setShowAllCommits((v) => !v)}
className="typography-micro text-muted-foreground hover:text-foreground"
>
{showAllCommits ? 'Show less' : 'Show all'}
{showAllCommits ? t('gitView.integrate.showLess') : t('gitView.integrate.showAll')}
</button>
)}
</div>
@@ -449,11 +455,11 @@ export const IntegrateCommitsSection: React.FC<{
</div>
))}
{commitSummaries.length === 0 && (
<div className="typography-meta text-muted-foreground">Preview unavailable.</div>
<div className="typography-meta text-muted-foreground">{t('gitView.integrate.previewUnavailable')}</div>
)}
{ui.plan.commits.length > commitSummaries.length && (
<div className="typography-micro text-muted-foreground/70">
Showing first {commitSummaries.length} commits.
{t('gitView.integrate.showingFirstCommits', { count: commitSummaries.length })}
</div>
)}
</div>
@@ -463,10 +469,10 @@ export const IntegrateCommitsSection: React.FC<{
{ui.kind === 'conflict' && (
<div className="rounded-md border border-border/60 bg-background/60 p-3 space-y-2">
<div className="typography-meta text-foreground">
Conflicts in {ui.details.unmergedFiles.length} files
{t('gitView.integrate.conflictsInFiles', { count: ui.details.unmergedFiles.length })}
</div>
<div className="typography-micro text-muted-foreground/80">
Current commit: <span className="font-mono">{ui.state.currentCommit.slice(0, 7)}</span>
{t('gitView.integrate.currentCommit')}: <span className="font-mono">{ui.state.currentCommit.slice(0, 7)}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{ui.details.unmergedFiles.slice(0, 6).map((file) => (
@@ -475,12 +481,12 @@ export const IntegrateCommitsSection: React.FC<{
</span>
))}
{ui.details.unmergedFiles.length > 6 && (
<span className="text-xs text-muted-foreground">+{ui.details.unmergedFiles.length - 6} more</span>
<span className="text-xs text-muted-foreground">{t('gitView.integrate.moreFiles', { count: ui.details.unmergedFiles.length - 6 })}</span>
)}
</div>
<div className="flex items-center gap-2 pt-1">
<Button size="sm" variant="ghost" className="typography-meta" onClick={() => void handleAbort()}>
Abort
{t('gitView.operation.abort')}
</Button>
<Button
size="sm"
@@ -490,7 +496,7 @@ export const IntegrateCommitsSection: React.FC<{
onClick={() => void handleResolveWithAi({ state: ui.state, details: ui.details }, false)}
>
<RiSparklingLine className="size-3.5" />
Current Session
{t('gitView.integrate.currentSession')}
</Button>
<Button
size="sm"
@@ -499,10 +505,10 @@ export const IntegrateCommitsSection: React.FC<{
onClick={() => void handleResolveWithAi({ state: ui.state, details: ui.details }, true)}
>
<RiSparklingLine className="size-3.5" />
New Session
{t('gitView.integrate.newSession')}
</Button>
<Button size="sm" className="typography-meta" onClick={() => void handleContinue()}>
Continue
{t('gitView.operation.continue')}
</Button>
</div>
</div>
@@ -63,6 +63,7 @@ import type {
GitHubPullRequestStatus,
GitRemote,
} from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
type MergeMethod = 'merge' | 'squash' | 'rebase';
@@ -278,6 +279,7 @@ export const PullRequestSection: React.FC<{
remoteBranches?: string[];
onGeneratedDescription?: () => void;
}> = ({ directory, branch, baseBranch, trackingBranch, remotes = [], remoteBranches = [], onGeneratedDescription }) => {
const { t } = useI18n();
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
@@ -516,7 +518,7 @@ export const PullRequestSection: React.FC<{
const openChecksDialog = React.useCallback(async () => {
if (!github?.prContext) {
toast.error('GitHub runtime API unavailable');
toast.error(t('gitView.pr.toast.githubApiUnavailable'));
return;
}
if (!pr) return;
@@ -532,7 +534,7 @@ export const PullRequestSection: React.FC<{
setCheckDetails(ctx);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to load check details', { description: message });
toast.error(t('gitView.pr.toast.loadCheckDetailsFailed'), { description: message });
} finally {
setIsLoadingCheckDetails(false);
}
@@ -540,7 +542,7 @@ export const PullRequestSection: React.FC<{
const openCommentsDialog = React.useCallback(async () => {
if (!github?.prContext) {
toast.error('GitHub runtime API unavailable');
toast.error(t('gitView.pr.toast.githubApiUnavailable'));
return;
}
if (!pr) return;
@@ -555,7 +557,7 @@ export const PullRequestSection: React.FC<{
setCommentsDetails(ctx);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to load comments', { description: message });
toast.error(t('gitView.pr.toast.loadCommentsFailed'), { description: message });
} finally {
setIsLoadingCommentsDetails(false);
}
@@ -594,11 +596,11 @@ export const PullRequestSection: React.FC<{
const issue = (commentsDetails?.issueComments ?? []).map((comment) => ({
id: `issue-${comment.id}`,
body: comment.body || '',
authorName: comment.author?.name || comment.author?.login || 'Unknown author',
authorName: comment.author?.name || comment.author?.login || t('gitView.pr.comments.unknownAuthor'),
authorLogin: comment.author?.login || null,
avatarUrl: comment.author?.avatarUrl || null,
createdAt: comment.createdAt,
context: 'General comment',
context: t('gitView.pr.comments.generalContext'),
path: null as string | null,
line: null as number | null,
}));
@@ -606,11 +608,11 @@ export const PullRequestSection: React.FC<{
const review = (commentsDetails?.reviewComments ?? []).map((comment) => ({
id: `review-${comment.id}`,
body: comment.body || '',
authorName: comment.author?.name || comment.author?.login || 'Unknown author',
authorName: comment.author?.name || comment.author?.login || t('gitView.pr.comments.unknownAuthor'),
authorLogin: comment.author?.login || null,
avatarUrl: comment.author?.avatarUrl || null,
createdAt: comment.createdAt,
context: 'Code review comment',
context: t('gitView.pr.comments.reviewContext'),
path: comment.path || null,
line: comment.line ?? null,
}));
@@ -624,11 +626,11 @@ export const PullRequestSection: React.FC<{
return aVal - bVal;
});
return all;
}, [commentsDetails]);
}, [commentsDetails, t]);
const resolveChatDispatchTarget = React.useCallback((): ChatDispatchTarget | null => {
if (!currentSessionId) {
toast.error('No active session', { description: 'Open a chat session first.' });
toast.error(t('gitView.pr.toast.noActiveSession'), { description: t('gitView.pr.toast.noActiveSessionDescription') });
return null;
}
@@ -637,7 +639,7 @@ export const PullRequestSection: React.FC<{
const providerID = currentProviderId || lastUsedProvider?.providerID;
const modelID = currentModelId || lastUsedProvider?.modelID;
if (!providerID || !modelID) {
toast.error('No model selected');
toast.error(t('gitView.pr.toast.noModelSelected'));
return null;
}
@@ -670,7 +672,7 @@ export const PullRequestSection: React.FC<{
target.currentVariant ?? undefined,
).catch((e) => {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to send message', { description: message });
toast.error(t('gitView.pr.toast.sendMessageFailed'), { description: message });
});
}, []);
@@ -743,7 +745,7 @@ export const PullRequestSection: React.FC<{
{run.job?.steps && run.job.steps.length > 0 ? (
<div className="space-y-1">
<div className="typography-micro text-muted-foreground">Steps</div>
<div className="typography-micro text-muted-foreground">{t('gitView.pr.checks.steps')}</div>
<div className="space-y-1">
{run.job.steps.map((step, idx) => {
const c = (step.conclusion || '').toLowerCase();
@@ -787,11 +789,11 @@ export const PullRequestSection: React.FC<{
</button>
<CollapsibleContent>
<div className="ml-6 mt-1 rounded border border-border/40 bg-transparent px-2 py-2 typography-micro text-muted-foreground space-y-1">
{typeof step.number === 'number' ? <div>Step: {step.number}</div> : null}
{step.status ? <div>Status: {step.status}</div> : null}
{step.conclusion ? <div>Conclusion: {step.conclusion}</div> : null}
{step.startedAt ? <div>Started: {formatTimestamp(step.startedAt)}</div> : null}
{step.completedAt ? <div>Completed: {formatTimestamp(step.completedAt)}</div> : null}
{typeof step.number === 'number' ? <div>{t('gitView.pr.checks.stepLabel')}: {step.number}</div> : null}
{step.status ? <div>{t('gitView.pr.checks.statusLabel')}: {step.status}</div> : null}
{step.conclusion ? <div>{t('gitView.pr.checks.conclusionLabel')}: {step.conclusion}</div> : null}
{step.startedAt ? <div>{t('gitView.pr.checks.startedLabel')}: {formatTimestamp(step.startedAt)}</div> : null}
{step.completedAt ? <div>{t('gitView.pr.checks.completedLabel')}: {formatTimestamp(step.completedAt)}</div> : null}
</div>
</CollapsibleContent>
</Collapsible>
@@ -808,7 +810,7 @@ export const PullRequestSection: React.FC<{
setActiveMainTab('chat');
if (!github?.prContext) {
toast.error('GitHub runtime API unavailable');
toast.error(t('gitView.pr.toast.githubApiUnavailable'));
return;
}
if (!directory || !pr) return;
@@ -827,7 +829,7 @@ export const PullRequestSection: React.FC<{
});
if (failed.length === 0) {
toast.message('No failed checks');
toast.message(t('gitView.pr.toast.noFailedChecks'));
return;
}
@@ -856,7 +858,7 @@ export const PullRequestSection: React.FC<{
dispatchSyntheticPrompt(target, visibleText, instructionsText, payloadText);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to load checks', { description: message });
toast.error(t('gitView.pr.toast.loadChecksFailed'), { description: message });
}
}, [directory, dispatchSyntheticPrompt, github, pr, resolveChatDispatchTarget, setActiveMainTab]);
@@ -864,7 +866,7 @@ export const PullRequestSection: React.FC<{
setActiveMainTab('chat');
if (!github?.prContext) {
toast.error('GitHub runtime API unavailable');
toast.error(t('gitView.pr.toast.githubApiUnavailable'));
return;
}
if (!directory || !pr) return;
@@ -879,7 +881,7 @@ export const PullRequestSection: React.FC<{
const reviewComments = context.reviewComments ?? [];
const total = issueComments.length + reviewComments.length;
if (total === 0) {
toast.message('No PR comments');
toast.message(t('gitView.pr.toast.noPrComments'));
return;
}
@@ -895,7 +897,7 @@ export const PullRequestSection: React.FC<{
dispatchSyntheticPrompt(target, visibleText, instructionsText, payloadText);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to load PR comments', { description: message });
toast.error(t('gitView.pr.toast.loadPrCommentsFailed'), { description: message });
}
}, [directory, dispatchSyntheticPrompt, github, pr, resolveChatDispatchTarget, setActiveMainTab]);
@@ -1136,7 +1138,7 @@ export const PullRequestSection: React.FC<{
onGeneratedDescription?.();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to generate description', { description: message });
toast.error(t('gitView.pr.toast.generateDescriptionFailed'), { description: message });
} finally {
setIsGenerating(false);
}
@@ -1144,22 +1146,22 @@ export const PullRequestSection: React.FC<{
const createPr = React.useCallback(async () => {
if (!github?.prCreate) {
toast.error('GitHub runtime API unavailable');
toast.error(t('gitView.pr.toast.githubApiUnavailable'));
return;
}
const trimmedTitle = title.trim();
if (!trimmedTitle) {
toast.error('Title is required');
toast.error(t('gitView.pr.toast.titleRequired'));
return;
}
const trimmedBase = targetBaseBranch.trim();
if (!trimmedBase) {
toast.error('Base branch is required');
toast.error(t('gitView.pr.toast.baseBranchRequired'));
return;
}
if (trimmedBase === branch) {
toast.error('Base branch must differ from head branch');
toast.error(t('gitView.pr.toast.baseMustDifferFromHead'));
return;
}
@@ -1176,13 +1178,13 @@ export const PullRequestSection: React.FC<{
draft,
...(selectedRemote ? { remote: selectedRemote.name } : {}),
});
toast.success('PR created');
toast.success(t('gitView.pr.toast.prCreated'));
updatePrStatus(prStatusKey, (prev) => (prev ? { ...prev, pr } : prev));
await refresh({ force: true });
scheduleActionRefresh();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to create PR', { description: message });
toast.error(t('gitView.pr.toast.createPrFailed'), { description: message });
} finally {
setIsCreating(false);
}
@@ -1190,22 +1192,22 @@ export const PullRequestSection: React.FC<{
const mergePr = React.useCallback(async (pr: GitHubPullRequest) => {
if (!github?.prMerge) {
toast.error('GitHub runtime API unavailable');
toast.error(t('gitView.pr.toast.githubApiUnavailable'));
return;
}
setIsMerging(true);
try {
const result = await github.prMerge({ directory, number: pr.number, method: mergeMethod });
if (result.merged) {
toast.success('PR merged');
toast.success(t('gitView.pr.toast.prMerged'));
} else {
toast.message('PR not merged', { description: result.message || 'Not mergeable' });
toast.message(t('gitView.pr.toast.prNotMerged'), { description: result.message || t('gitView.pr.notMergeable') });
}
await refresh({ force: true });
scheduleActionRefresh();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Merge failed', { description: message });
toast.error(t('gitView.pr.toast.mergeFailed'), { description: message });
if (pr.url) {
void openExternal(pr.url);
}
@@ -1216,18 +1218,18 @@ export const PullRequestSection: React.FC<{
const markReady = React.useCallback(async (pr: GitHubPullRequest) => {
if (!github?.prReady) {
toast.error('GitHub runtime API unavailable');
toast.error(t('gitView.pr.toast.githubApiUnavailable'));
return;
}
setIsMarkingReady(true);
try {
await github.prReady({ directory, number: pr.number });
toast.success('Marked ready for review');
toast.success(t('gitView.pr.toast.markedReady'));
await refresh({ force: true });
scheduleActionRefresh();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to mark ready', { description: message });
toast.error(t('gitView.pr.toast.markReadyFailed'), { description: message });
if (pr.url) {
void openExternal(pr.url);
}
@@ -1238,13 +1240,13 @@ export const PullRequestSection: React.FC<{
const updatePr = React.useCallback(async (pr: GitHubPullRequest) => {
if (!github?.prUpdate) {
toast.error('GitHub runtime API unavailable');
toast.error(t('gitView.pr.toast.githubApiUnavailable'));
return;
}
const trimmedTitle = editTitle.trim();
if (!trimmedTitle) {
toast.error('Title is required');
toast.error(t('gitView.pr.toast.titleRequired'));
return;
}
@@ -1266,12 +1268,12 @@ export const PullRequestSection: React.FC<{
}
: prev));
setIsEditingPr(false);
toast.success('PR updated');
toast.success(t('gitView.pr.toast.prUpdated'));
await refresh({ force: true });
scheduleActionRefresh();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to update PR', { description: message });
toast.error(t('gitView.pr.toast.updatePrFailed'), { description: message });
} finally {
setIsUpdating(false);
}
@@ -1312,17 +1314,17 @@ export const PullRequestSection: React.FC<{
type="button"
className="inline-flex size-6 shrink-0 items-center justify-center rounded-md border border-border/60 bg-background/70 hover:bg-interactive-hover/60"
onClick={() => void openExternal(pr.url)}
aria-label="Open PR on GitHub"
aria-label={t('gitView.pr.actions.openOnGitHubAria')}
>
<PrStateIcon className="size-4 shrink-0" style={{ color: prColorVar }} />
</button>
</TooltipTrigger>
<TooltipContent><p>Open PR on GitHub</p></TooltipContent>
<TooltipContent><p>{t('gitView.pr.actions.openOnGitHub')}</p></TooltipContent>
</Tooltip>
) : (
<PrStateIcon className="size-4 shrink-0" style={{ color: 'var(--surface-muted-foreground)' }} />
)}
<h3 className="typography-ui-header font-semibold text-foreground truncate">Pull Request</h3>
<h3 className="typography-ui-header font-semibold text-foreground truncate">{t('gitView.pullRequest.title')}</h3>
{pr ? (
<span className="typography-meta text-muted-foreground truncate">#{pr.number}</span>
) : null}
@@ -1372,7 +1374,7 @@ export const PullRequestSection: React.FC<{
<span style={{ color: prColorVar }}>
{pr.state}{pr.draft ? ' (draft)' : ''}
</span>
{pr.mergeable === false ? ' · not mergeable' : ''}
{pr.mergeable === false ? ` · ${t('gitView.pr.notMergeable')}` : ''}
{pr.state === 'open' && typeof pr.mergeableState === 'string' && pr.mergeableState && pr.mergeableState !== 'unknown'
? ` · ${pr.mergeableState}`
: ''}
@@ -1383,18 +1385,18 @@ export const PullRequestSection: React.FC<{
<div className={bodyClassName}>
{shouldShowConnectionNotice ? (
<div className="space-y-2">
<div className="typography-meta text-muted-foreground">
GitHub not connected. Connect your GitHub account in settings.
<div className="typography-meta text-muted-foreground">
{t('gitView.pr.githubNotConnected')}
</div>
<Button variant="outline" size="sm" onClick={openGitHubSettings} className="w-fit">
Open settings
{t('gitView.pr.actions.openSettings')}
</Button>
</div>
) : null}
{error ? (
<div className="space-y-2">
<div className="typography-ui-label text-foreground">PR status unavailable</div>
<div className="typography-ui-label text-foreground">{t('gitView.pr.statusUnavailable')}</div>
<div className="typography-meta text-muted-foreground break-words">{error}</div>
{repoUrl ? (
<Button variant="outline" size="sm" asChild className="w-fit">
@@ -1410,7 +1412,7 @@ export const PullRequestSection: React.FC<{
{!pr && !isInitialStatusResolved && !error && !shouldShowConnectionNotice ? (
<div className="flex items-center gap-2 typography-micro text-muted-foreground">
<RiLoader4Line className="size-4 animate-spin" />
Checking PR status...
{t('gitView.pr.checkingStatus')}
</div>
) : pr ? (
<div className="flex flex-col gap-2">
@@ -1421,7 +1423,7 @@ export const PullRequestSection: React.FC<{
<Input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="PR title"
placeholder={t('gitView.pr.placeholder.title')}
autoCorrect={hasTouchInput ? "on" : "off"}
autoCapitalize={hasTouchInput ? "sentences" : "off"}
spellCheck={hasTouchInput}
@@ -1430,7 +1432,7 @@ export const PullRequestSection: React.FC<{
value={editBody}
onChange={(e) => setEditBody(e.target.value)}
className="min-h-[120px] bg-background/80"
placeholder="Describe this PR"
placeholder={t('gitView.pr.placeholder.description')}
autoCorrect={hasTouchInput ? "on" : "off"}
autoCapitalize={hasTouchInput ? "sentences" : "off"}
spellCheck={hasTouchInput}
@@ -1446,18 +1448,18 @@ export const PullRequestSection: React.FC<{
/>
) : (
<div className="typography-micro text-muted-foreground whitespace-pre-wrap break-words mt-1">
{isHydratingCurrentPrBody ? 'Loading description...' : 'No description provided.'}
{isHydratingCurrentPrBody ? t('gitView.pr.loadingDescription') : t('gitView.pr.noDescription')}
</div>
)}
</>
)}
{canMerge && pr.draft ? (
<div className="typography-micro text-muted-foreground">
Draft PRs must be marked ready before merge.
{t('gitView.pr.draftMustBeReady')}
</div>
) : null}
{!canMerge ? (
<div className="typography-micro text-muted-foreground">No merge permission; use Open in GitHub.</div>
<div className="typography-micro text-muted-foreground">{t('gitView.pr.noMergePermission')}</div>
) : null}
</div>
@@ -1478,12 +1480,12 @@ export const PullRequestSection: React.FC<{
setEditBody(pr.body || '');
}}
disabled={isUpdating}
aria-label="Cancel editing"
aria-label={t('gitView.pr.actions.cancelEditingAria')}
>
<RiCloseLine className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>Cancel editing</p></TooltipContent>
<TooltipContent><p>{t('gitView.pr.actions.cancelEditing')}</p></TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
@@ -1492,12 +1494,12 @@ export const PullRequestSection: React.FC<{
className="h-7 w-7 px-0"
onClick={() => updatePr(pr)}
disabled={isUpdating || !editTitle.trim()}
aria-label="Save PR title and description"
aria-label={t('gitView.pr.actions.savePrAria')}
>
{isUpdating ? <RiLoader4Line className="size-4 animate-spin" /> : <RiCheckLine className="size-4" />}
</Button>
</TooltipTrigger>
<TooltipContent><p>Save PR title and description</p></TooltipContent>
<TooltipContent><p>{t('gitView.pr.actions.savePr')}</p></TooltipContent>
</Tooltip>
</>
) : (
@@ -1508,12 +1510,12 @@ export const PullRequestSection: React.FC<{
size="sm"
className="h-7 w-7 px-0"
onClick={() => setIsEditingPr(true)}
aria-label="Edit PR title and description"
aria-label={t('gitView.pr.actions.editPrAria')}
>
<RiEditLine className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>Edit PR title and description</p></TooltipContent>
<TooltipContent><p>{t('gitView.pr.actions.editPr')}</p></TooltipContent>
</Tooltip>
)
) : null}
@@ -1527,12 +1529,12 @@ export const PullRequestSection: React.FC<{
className="h-7 w-7 px-0"
onClick={openChecksDialog}
disabled={isLoadingCheckDetails}
aria-label="Open checks details"
aria-label={t('gitView.pr.actions.openChecksAria')}
>
{isLoadingCheckDetails ? <RiLoader4Line className="size-4 animate-spin" /> : <RiInformationLine className="size-4" />}
</Button>
</TooltipTrigger>
<TooltipContent><p>Open checks details</p></TooltipContent>
<TooltipContent><p>{t('gitView.pr.actions.openChecks')}</p></TooltipContent>
</Tooltip>
) : null}
@@ -1544,12 +1546,12 @@ export const PullRequestSection: React.FC<{
size="sm"
className="h-7 w-7 px-0 border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)]"
onClick={sendFailedChecksToChat}
aria-label="Resolve failed checks with agent"
aria-label={t('gitView.pr.actions.resolveFailedChecksAria')}
>
<RiErrorWarningLine className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>Resolve failed checks with agent</p></TooltipContent>
<TooltipContent><p>{t('gitView.pr.actions.resolveFailedChecks')}</p></TooltipContent>
</Tooltip>
) : null}
@@ -1560,12 +1562,12 @@ export const PullRequestSection: React.FC<{
size="sm"
className="h-7 w-7 px-0"
onClick={openCommentsDialog}
aria-label="Open PR comments"
aria-label={t('gitView.pr.actions.openCommentsAria')}
>
<RiChat4Line className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>Open PR comments</p></TooltipContent>
<TooltipContent><p>{t('gitView.pr.actions.openComments')}</p></TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
@@ -1575,12 +1577,12 @@ export const PullRequestSection: React.FC<{
size="sm"
className="h-7 w-7 px-0 border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)]"
onClick={sendCommentsToChat}
aria-label="Share comments with agent"
aria-label={t('gitView.pr.actions.shareCommentsAria')}
>
<RiAiGenerate2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>Share comments with agent</p></TooltipContent>
<TooltipContent><p>{t('gitView.pr.actions.shareComments')}</p></TooltipContent>
</Tooltip>
{canMerge && pr.draft && pr.state === 'open' ? (
@@ -1592,12 +1594,12 @@ export const PullRequestSection: React.FC<{
className="h-7 w-7 px-0"
onClick={() => markReady(pr)}
disabled={isMarkingReady || isMerging || isUpdating || isEditingPr}
aria-label="Mark PR ready for review"
aria-label={t('gitView.pr.actions.markReadyAria')}
>
{isMarkingReady ? <RiLoader4Line className="size-4 animate-spin" /> : <RiCheckboxCircleLine className="size-4" />}
</Button>
</TooltipTrigger>
<TooltipContent><p>Mark PR ready for review</p></TooltipContent>
<TooltipContent><p>{t('gitView.pr.actions.markReady')}</p></TooltipContent>
</Tooltip>
) : null}
</div>
@@ -1614,9 +1616,9 @@ export const PullRequestSection: React.FC<{
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="squash">Squash</SelectItem>
<SelectItem value="merge">Merge</SelectItem>
<SelectItem value="rebase">Rebase</SelectItem>
<SelectItem value="squash">{t('gitView.pr.mergeMethod.squash')}</SelectItem>
<SelectItem value="merge">{t('gitView.pr.mergeMethod.merge')}</SelectItem>
<SelectItem value="rebase">{t('gitView.pr.mergeMethod.rebase')}</SelectItem>
</SelectContent>
</Select>
<Tooltip delayDuration={300}>
@@ -1626,12 +1628,12 @@ export const PullRequestSection: React.FC<{
className="h-7 w-7 px-0"
onClick={() => mergePr(pr)}
disabled={isMerging || isMarkingReady || pr.state !== 'open' || pr.draft || isUpdating || isEditingPr}
aria-label="Merge pull request"
aria-label={t('gitView.pr.actions.mergePrAria')}
>
{isMerging ? <RiLoader4Line className="size-4 animate-spin" /> : <RiGitMergeLine className="size-4" />}
</Button>
</TooltipTrigger>
<TooltipContent><p>Merge pull request</p></TooltipContent>
<TooltipContent><p>{t('gitView.pr.actions.mergePr')}</p></TooltipContent>
</Tooltip>
</>
) : null}
@@ -1643,7 +1645,7 @@ export const PullRequestSection: React.FC<{
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="typography-ui-label text-foreground">Create PR</div>
<div className="typography-ui-label text-foreground">{t('gitView.pr.createTitle')}</div>
<div className="typography-micro text-muted-foreground truncate">
{branch} {targetBaseBranch}
</div>
@@ -1652,18 +1654,18 @@ export const PullRequestSection: React.FC<{
<Button variant="outline" size="sm" asChild>
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
<RiExternalLinkLine className="size-4" />
Repo
{t('gitView.pr.actions.repo')}
</a>
</Button>
) : null}
</div>
<label className="space-y-1">
<div className="typography-micro text-muted-foreground">Title</div>
<div className="typography-micro text-muted-foreground">{t('gitView.pr.field.title')}</div>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="PR title"
placeholder={t('gitView.pr.placeholder.title')}
autoCorrect={hasTouchInput ? "on" : "off"}
autoCapitalize={hasTouchInput ? "sentences" : "off"}
spellCheck={hasTouchInput}
@@ -1671,11 +1673,11 @@ export const PullRequestSection: React.FC<{
</label>
<label className="space-y-1">
<div className="typography-micro text-muted-foreground">Base branch</div>
<div className="typography-micro text-muted-foreground">{t('gitView.pr.field.baseBranch')}</div>
{availableBaseBranches.length > 0 ? (
<Select value={targetBaseBranch} onValueChange={setTargetBaseBranch}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select base branch" />
<SelectValue placeholder={t('gitView.pr.placeholder.selectBaseBranch')} />
</SelectTrigger>
<SelectContent>
{availableBaseBranches.map((candidate) => (
@@ -1687,18 +1689,18 @@ export const PullRequestSection: React.FC<{
<Input
value={targetBaseBranch}
onChange={(e) => setTargetBaseBranch(e.target.value)}
placeholder="main"
placeholder={t('gitView.pr.placeholder.main')}
/>
)}
</label>
<label className="space-y-1">
<div className="typography-micro text-muted-foreground">Description</div>
<div className="typography-micro text-muted-foreground">{t('gitView.pr.field.description')}</div>
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
className="min-h-[110px]"
placeholder="What changed and why"
placeholder={t('gitView.pr.placeholder.whatChanged')}
autoCorrect={hasTouchInput ? "on" : "off"}
autoCapitalize={hasTouchInput ? "sentences" : "off"}
spellCheck={hasTouchInput}
@@ -1722,9 +1724,9 @@ export const PullRequestSection: React.FC<{
size="sm"
checked={draft}
onChange={(next) => setDraft(next)}
ariaLabel="Toggle draft PR"
ariaLabel={t('gitView.pr.actions.toggleDraftAria')}
/>
<span className="typography-ui-label text-foreground select-none">Draft</span>
<span className="typography-ui-label text-foreground select-none">{t('gitView.pr.field.draft')}</span>
</div>
{/* Additional Context Section */}
@@ -1732,20 +1734,20 @@ export const PullRequestSection: React.FC<{
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="typography-micro text-muted-foreground">
Additional context (optional)
{t('gitView.pr.additionalContext.optional')}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setIsContextSheetOpen(true)}
>
{additionalContext.trim() ? 'Edit' : 'Add'}
{additionalContext.trim() ? t('gitView.pr.actions.edit') : t('gitView.pr.actions.add')}
</Button>
</div>
{additionalContext.trim() && (
<div className="flex items-center gap-2">
<span className="inline-flex items-center rounded-full bg-[var(--interactive-selection)] px-2 py-0.5 text-xs text-[var(--interactive-selection-foreground)]">
Context added
{t('gitView.pr.additionalContext.added')}
</span>
</div>
)}
@@ -1754,10 +1756,10 @@ export const PullRequestSection: React.FC<{
<Collapsible open={isContextOpen} onOpenChange={setIsContextOpen}>
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-3 py-2 hover:bg-[var(--interactive-hover)]">
<span className="typography-micro text-muted-foreground">
Additional context (optional)
{t('gitView.pr.additionalContext.optional')}
</span>
<span className="typography-micro text-[var(--primary-base)]">
{isContextOpen ? 'Hide' : additionalContext.trim() ? 'Edit' : 'Add'}
{isContextOpen ? t('gitView.pr.actions.hide') : additionalContext.trim() ? t('gitView.pr.actions.edit') : t('gitView.pr.actions.add')}
</span>
</CollapsibleTrigger>
<CollapsibleContent>
@@ -1766,10 +1768,10 @@ export const PullRequestSection: React.FC<{
value={additionalContext}
onChange={(e) => setAdditionalContext(e.target.value)}
className="min-h-[100px] bg-transparent"
placeholder="Explain why this change is needed...&#10;Mention how to test (commands / steps)...&#10;Call out risks / rollout plan..."
placeholder={t('gitView.pr.placeholder.additionalContext')}
/>
<p className="typography-micro text-muted-foreground">
This text is only used to guide PR generation.
{t('gitView.pr.additionalContext.hint')}
</p>
</div>
</CollapsibleContent>
@@ -1780,14 +1782,14 @@ export const PullRequestSection: React.FC<{
<MobileOverlayPanel
open={isContextSheetOpen}
onClose={() => setIsContextSheetOpen(false)}
title="Additional context"
title={t('gitView.pr.additionalContext.title')}
footer={
<Button
size="sm"
onClick={() => setIsContextSheetOpen(false)}
className="w-full"
>
Done
{t('gitView.common.done')}
</Button>
}
>
@@ -1796,11 +1798,11 @@ export const PullRequestSection: React.FC<{
value={additionalContext}
onChange={(e) => setAdditionalContext(e.target.value)}
className="min-h-[200px] bg-transparent"
placeholder="Explain why this change is needed...&#10;Mention how to test (commands / steps)...&#10;Call out risks / rollout plan..."
placeholder={t('gitView.pr.placeholder.additionalContext')}
autoFocus
/>
<p className="typography-micro text-muted-foreground">
This text is only used to guide PR generation.
{t('gitView.pr.additionalContext.hint')}
</p>
</div>
</MobileOverlayPanel>
@@ -1813,7 +1815,7 @@ export const PullRequestSection: React.FC<{
disabled={isGenerating || isCreating}
>
{isGenerating ? <RiLoader4Line className="size-4 animate-spin" /> : <RiAiGenerate2 className="size-4 text-primary" />}
Generate
{t('gitView.commit.generate')}
</Button>
<div className="flex-1" />
<Button
@@ -1825,7 +1827,7 @@ export const PullRequestSection: React.FC<{
<span className="inline-flex size-4 items-center justify-center">
{isCreating ? <RiLoader4Line className="size-4 animate-spin" /> : <RiGitPullRequestLine className="size-4" />}
</span>
<span>Create PR</span>
<span>{t('gitView.pr.actions.createPr')}</span>
</Button>
</div>
</div>
@@ -1837,10 +1839,10 @@ export const PullRequestSection: React.FC<{
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiGitPullRequestLine className="h-5 w-5" />
Check Details
{t('gitView.pr.checkDetails.title')}
</DialogTitle>
<DialogDescription>
{pr ? `PR #${pr.number}` : 'Pull request'}
{pr ? t('gitView.pr.numberLabel', { number: pr.number }) : t('gitView.pullRequest.title')}
</DialogDescription>
</DialogHeader>
@@ -1848,7 +1850,7 @@ export const PullRequestSection: React.FC<{
{isLoadingCheckDetails ? (
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading...
{t('gitView.loading.loading')}
</div>
) : null}
@@ -1864,7 +1866,7 @@ export const PullRequestSection: React.FC<{
);
})
) : (
<div className="text-center text-muted-foreground py-8">No check details available.</div>
<div className="text-center text-muted-foreground py-8">{t('gitView.pr.checkDetails.empty')}</div>
)}
</div>
) : null}
@@ -1878,9 +1880,9 @@ export const PullRequestSection: React.FC<{
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiGitPullRequestLine className="h-5 w-5" />
PR Comments
{t('gitView.pr.comments.title')}
{pr ? (
<span className="typography-meta text-muted-foreground">PR #{pr.number}</span>
<span className="typography-meta text-muted-foreground">{t('gitView.pr.numberLabel', { number: pr.number })}</span>
) : null}
</DialogTitle>
</DialogHeader>
@@ -1889,7 +1891,7 @@ export const PullRequestSection: React.FC<{
{isLoadingCommentsDetails ? (
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading...
{t('gitView.loading.loading')}
</div>
) : null}
@@ -1927,13 +1929,13 @@ export const PullRequestSection: React.FC<{
onClick={() => {
void sendSingleCommentToChat(comment);
}}
aria-label="Send this comment to agent"
aria-label={t('gitView.pr.actions.sendCommentToAgentAria')}
>
<RiAiGenerate2 className="size-3.5" />
Send to agent
{t('gitView.pr.actions.sendToAgent')}
</Button>
</TooltipTrigger>
<TooltipContent><p>Send this comment to agent</p></TooltipContent>
<TooltipContent><p>{t('gitView.pr.actions.sendCommentToAgent')}</p></TooltipContent>
</Tooltip>
</div>
<div className="typography-micro text-muted-foreground">
@@ -1955,7 +1957,7 @@ export const PullRequestSection: React.FC<{
</div>
</div>
) : (
<div className="text-center text-muted-foreground py-8">No comments found.</div>
<div className="text-center text-muted-foreground py-8">{t('gitView.pr.comments.empty')}</div>
)}
</div>
) : null}
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from '@/components/ui';
import { RiAlertLine, RiLoader4Line } from '@remixicon/react';
import { useI18n } from '@/lib/i18n';
interface StashDialogProps {
open: boolean;
@@ -27,10 +28,11 @@ export const StashDialog: React.FC<StashDialogProps> = ({
targetBranch,
onConfirm,
}) => {
const { t } = useI18n();
const [restoreAfter, setRestoreAfter] = React.useState(true);
const [isProcessing, setIsProcessing] = React.useState(false);
const operationLabel = operation === 'merge' ? 'Merge' : 'Rebase';
const operationLabel = operation === 'merge' ? t('gitView.operation.merge') : t('gitView.operation.rebase');
const handleConfirm = async () => {
setIsProcessing(true);
@@ -58,26 +60,25 @@ export const StashDialog: React.FC<StashDialogProps> = ({
<DialogHeader>
<div className="flex items-center gap-2">
<RiAlertLine className="size-5 text-[var(--status-warning)]" />
<DialogTitle>Uncommitted Changes</DialogTitle>
<DialogTitle>{t('gitView.stash.title')}</DialogTitle>
</div>
<DialogDescription>
You have uncommitted changes that would be overwritten by this {operation}.
Would you like to stash them temporarily?
{t('gitView.stash.description', { operation })}
</DialogDescription>
</DialogHeader>
<div className="py-2">
<p className="typography-meta text-muted-foreground mb-3">
This will:
{t('gitView.stash.thisWill')}
</p>
<ol className="list-decimal list-inside space-y-1 typography-meta text-foreground">
<li>Stash your uncommitted changes</li>
<li>{t('gitView.stash.stepStash')}</li>
<li>
{operation === 'merge' ? 'Merge' : 'Rebase'}{' '}
{operation === 'merge' ? 'with' : 'onto'}{' '}
{operation === 'merge' ? t('gitView.operation.merge') : t('gitView.operation.rebase')}{' '}
{operation === 'merge' ? t('gitView.stash.mergeWith') : t('gitView.stash.rebaseOnto')}{' '}
<span className="font-mono text-primary">{targetBranch}</span>
</li>
{restoreAfter && <li>Restore your stashed changes</li>}
{restoreAfter && <li>{t('gitView.stash.stepRestore')}</li>}
</ol>
</div>
@@ -86,13 +87,13 @@ export const StashDialog: React.FC<StashDialogProps> = ({
checked={restoreAfter}
onChange={setRestoreAfter}
disabled={isProcessing}
ariaLabel="Restore changes after operation"
ariaLabel={t('gitView.stash.restoreAria')}
/>
<span
className="typography-ui-label text-foreground cursor-pointer select-none"
onClick={() => !isProcessing && setRestoreAfter(!restoreAfter)}
>
Restore changes after the {operation}
{t('gitView.stash.restoreAfterOperation', { operation })}
</span>
</div>
@@ -103,7 +104,7 @@ export const StashDialog: React.FC<StashDialogProps> = ({
onClick={handleCancel}
disabled={isProcessing}
>
Cancel
{t('gitView.common.cancel')}
</Button>
<Button
variant="default"
@@ -115,10 +116,10 @@ export const StashDialog: React.FC<StashDialogProps> = ({
{isProcessing ? (
<>
<RiLoader4Line className="size-4 animate-spin" />
Processing...
{t('gitView.common.processing')}
</>
) : (
`Stash & ${operationLabel}`
t('gitView.stash.confirmButton', { operation: operationLabel })
)}
</Button>
</DialogFooter>
@@ -15,6 +15,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { GitRemote } from '@/lib/gitApi';
import { useI18n } from '@/lib/i18n';
type SyncAction = 'fetch' | 'pull' | 'push' | null;
@@ -47,6 +48,7 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
aheadCount = 0,
behindCount = 0,
}) => {
const { t } = useI18n();
const skipRemoteSelectRef = React.useRef(false);
const hasNoRemotes = remotes.length === 0;
const isRemovingRemote = Boolean(removingRemoteName);
@@ -192,8 +194,8 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
event.stopPropagation();
onRemoveRemote(remote);
}}
aria-label={`Remove ${remote.name} remote`}
title={`Remove ${remote.name}`}
aria-label={t('gitView.header.removeRemoteAria', { name: remote.name })}
title={t('gitView.header.removeRemoteTitle', { name: remote.name })}
>
{removingRemoteName === remote.name ? (
<RiLoader4Line className="size-3.5 animate-spin" />
@@ -217,17 +219,17 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
'fetch',
<RiRefreshLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
'Fetch',
t('gitView.sync.fetch'),
onFetch,
'Fetch from remote'
t('gitView.sync.fetchTooltip')
)
: renderButton(
'fetch',
<RiRefreshLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
'Fetch',
t('gitView.sync.fetch'),
handleFetch,
'Fetch from remote'
t('gitView.sync.fetchTooltip')
)}
{hasMultipleRemotes
@@ -235,18 +237,22 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
'pull',
<RiArrowDownLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
'Pull',
t('gitView.sync.pull'),
onPull,
behindCount > 0 ? `Pull changes (${behindCount} behind)` : 'Pull changes',
behindCount > 0
? t('gitView.sync.pullTooltipBehind', { count: behindCount })
: t('gitView.sync.pullTooltip'),
behindCount
)
: renderButton(
'pull',
<RiArrowDownLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
'Pull',
t('gitView.sync.pull'),
handlePull,
behindCount > 0 ? `Pull changes (${behindCount} behind)` : 'Pull changes',
behindCount > 0
? t('gitView.sync.pullTooltipBehind', { count: behindCount })
: t('gitView.sync.pullTooltip'),
behindCount
)}
@@ -254,9 +260,11 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
'push',
<RiArrowUpLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
'Push',
t('gitView.sync.push'),
handlePush,
aheadCount > 0 ? `Push changes (${aheadCount} ahead)` : 'Push changes',
aheadCount > 0
? t('gitView.sync.pushTooltipAhead', { count: aheadCount })
: t('gitView.sync.pushTooltip'),
aheadCount
)}
</div>
@@ -1,6 +1,7 @@
import React from 'react';
import { RiGitBranchLine, RiEditLine, RiCheckLine, RiCloseLine, RiLoader4Line } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/i18n';
interface WorktreeBranchDisplayProps {
currentBranch: string | null | undefined;
@@ -26,6 +27,7 @@ export const WorktreeBranchDisplay: React.FC<WorktreeBranchDisplayProps> = ({
onRename,
showEditButton = true,
}) => {
const { t } = useI18n();
const [isEditing, setIsEditing] = React.useState(false);
const [editBranchName, setEditBranchName] = React.useState(currentBranch || '');
const [isRenaming, setIsRenaming] = React.useState(false);
@@ -90,7 +92,7 @@ export const WorktreeBranchDisplay: React.FC<WorktreeBranchDisplayProps> = ({
value={editBranchName}
onChange={(e) => setEditBranchName(e.target.value)}
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
placeholder="Branch name"
placeholder={t('gitView.branch.namePlaceholder')}
onKeyDown={handleKeyDown}
autoFocus
/>
@@ -123,7 +125,7 @@ export const WorktreeBranchDisplay: React.FC<WorktreeBranchDisplayProps> = ({
<RiGitBranchLine className="size-4 text-primary shrink-0" />
<div className="inline-flex min-w-0 max-w-full items-center gap-1">
<span className="truncate typography-ui-label font-normal text-foreground">
{currentBranch || 'Detached HEAD'}
{currentBranch || t('gitView.branch.detachedHead')}
</span>
{showEditButton && onRename && currentBranch && (
<Button
@@ -131,7 +133,7 @@ export const WorktreeBranchDisplay: React.FC<WorktreeBranchDisplayProps> = ({
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={handleStartEdit}
title="Rename branch"
title={t('gitView.branch.renameTitle')}
>
<RiEditLine className="size-4" />
</Button>