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:
committed by
GitHub
parent
87db2ea210
commit
7d7285655d
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 "Resolve with AI" 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... Mention how to test (commands / steps)... 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... Mention how to test (commands / steps)... 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>
|
||||
|
||||
Reference in New Issue
Block a user