Add Windows Electron desktop support (#1093)

* fix: make upstream sync actions target the selected remote

Ensure fetch and pull actually honor upstream selection so fork maintenance works from the Git sidebar, and surface upstream branch status alongside the primary origin-tracking indicators.

* feat: add Windows Electron desktop foundation

* fix(electron): stabilize Windows desktop packaging

* fix(electron): stabilize Windows desktop chrome

Use native Windows titlebar behavior with an Alt-accessible hidden menu, and harden Windows dev command launching so the desktop app follows platform conventions.

* fix(electron): stabilize Windows dev startup

* fix(electron): clarify desktop artifact names

* fix(electron): harden Windows desktop release and launch

* fix(electron): address Windows release review

* fix(electron): point updater and release links to org repo

* Fix Windows settings persistence fallback

* Fix Windows Electron dev startup

* Add Windows Electron window controls

* Fix Windows Electron install and opencode launch

* fix: resolve git status for repositories without upstream

Fixes repository detection stuck on Checking repository
Handles git status when no upstream is configured
Adds regression coverage for git status loading

* Add Windows app menu button

* fix: preserve file editor line endings

* ci: add desktop release smoke workflow

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Dave Otero
2026-05-26 18:13:59 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent cc7969ac00
commit becd240168
59 changed files with 2260 additions and 246 deletions
@@ -55,29 +55,39 @@ const submitPassword = async (password: string, trustDevice: boolean): Promise<R
return response;
};
const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div
className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background text-foreground"
style={{ fontFamily: '"Inter", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif' }}
>
const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const titlebarDragStyle = React.useMemo<React.CSSProperties>(() => {
return {
height: 'var(--oc-wco-titlebar-height, 0px)',
right: 'var(--oc-wco-right-inset, 0px)',
};
}, []);
return (
<div
className="pointer-events-none absolute inset-0 opacity-55"
style={{
background: 'radial-gradient(120% 140% at 50% -20%, var(--surface-overlay) 0%, transparent 68%)',
}}
/>
<div
className="pointer-events-none absolute inset-0"
style={{
backgroundColor: 'var(--surface-subtle)',
opacity: 0.22,
}}
/>
<div className="relative z-10 flex w-full justify-center px-4 py-12 sm:px-6">
{children}
className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background text-foreground"
style={{ fontFamily: '"Inter", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif' }}
>
<div className="app-region-drag fixed left-0 top-0 z-20" style={titlebarDragStyle} aria-hidden />
<div
className="pointer-events-none absolute inset-0 opacity-55"
style={{
background: 'radial-gradient(120% 140% at 50% -20%, var(--surface-overlay) 0%, transparent 68%)',
}}
/>
<div
className="pointer-events-none absolute inset-0"
style={{
backgroundColor: 'var(--surface-subtle)',
opacity: 0.22,
}}
/>
<div className="app-region-no-drag relative z-10 flex w-full justify-center px-4 py-12 sm:px-6">
{children}
</div>
</div>
</div>
);
);
};
const LoadingScreen: React.FC = () => (
<div className="flex min-h-screen items-center justify-center bg-background text-foreground">
@@ -316,7 +316,7 @@ FileChip.displayName = 'FileChip';
const VSCodeFileChip = memo(({ file, onRemove }: FileChipProps) => {
const { t } = useI18n();
const { displayName, extension } = useFileDetails(file);
// Detect selection-style attachments: ends with ":N" or ":N-M"
const isSelectionAttachment = /:\d+(?:-\d+)?$/.test(displayName);
@@ -359,7 +359,7 @@ interface AttachedFilesListProps {
onShowPopup?: (content: ToolPopupContent) => void;
}
export const AttachedVSCodeFileChips = memo(({ onShowPopup }: AttachedFilesListProps) => {
export const AttachedVSCodeFileChips = memo(({ onShowPopup }: AttachedFilesListProps) => {
const attachedFiles = useInputStore((state) => state.attachedFiles);
const removeAttachedFile = useInputStore((state) => state.removeAttachedFile);
@@ -491,7 +491,6 @@ function SessionItem({
title={`Sub-session: ${getSessionTitle(child)}`}
>
<Icon name="loader-4" className="h-2.5 w-2.5 animate-spin"
style={{ color: `var(${childColor.var})` }}/>
</div>
);
@@ -692,7 +691,6 @@ function SessionStatusHeader({
title={`Sub-session: ${child.session.title || 'Untitled'}`}
>
<Icon name="loader-4" className="h-2.5 w-2.5 animate-spin"
style={{ color: `var(${childColor.var})` }}/>
</div>
);
@@ -39,7 +39,6 @@ import { useI18n } from '@/lib/i18n';
import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness';
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
type IconComponent = IconName;
type ProviderModel = Record<string, unknown> & { id?: string; name?: string };
@@ -39,7 +39,6 @@ const MessageHeader: React.FC<MessageHeaderProps> = ({ isUser, providerID, agent
/>
) : (
<Icon name="brain-ai-3" className="h-4 w-4"
style={{ color: `var(${getAgentColor(agentName).var})` }}/>
)}
</div>
@@ -729,7 +729,6 @@ const ToolScrollableSection: React.FC<ToolScrollableSectionProps> = ({
disableHorizontal ? 'overflow-y-auto overflow-x-hidden' : 'overflow-auto',
className,
)}
>
<div className="w-full min-w-0">
{children}
@@ -153,6 +153,7 @@ export const iconSpriteData = {
"lock-unlock": `<path d="M7 10H20C20.5523 10 21 10.4477 21 11V21C21 21.5523 20.5523 22 20 22H4C3.44772 22 3 21.5523 3 21V11C3 10.4477 3.44772 10 4 10H5V9C5 5.13401 8.13401 2 12 2C14.7405 2 17.1131 3.5748 18.2624 5.86882L16.4731 6.76344C15.6522 5.12486 13.9575 4 12 4C9.23858 4 7 6.23858 7 9V10ZM5 12V20H19V12H5ZM10 15H14V17H10V15Z" fill="currentColor"/>`,
"loop-right-ai": `<path d="M22 12C22 17.5228 17.5228 22 12 22C8.72774 22 5.82382 20.4286 4 18.001V20.5H2V14.5H8V16.5H5.38477C6.82543 18.6137 9.25151 20 12 20C16.4183 20 20 16.4183 20 12H22ZM11.5293 8.31934C11.7059 7.8935 12.2943 7.89349 12.4707 8.31934L12.7236 8.93066C13.1556 9.97346 13.9615 10.8062 14.9746 11.2568L15.6924 11.5762C16.1026 11.759 16.1026 12.3562 15.6924 12.5391L14.9326 12.877C13.9449 13.3162 13.1534 14.1194 12.7139 15.1279L12.4668 15.6934C12.2864 16.1075 11.7137 16.1075 11.5332 15.6934L11.2871 15.1279C10.8476 14.1193 10.0552 13.3163 9.06738 12.877L8.30762 12.5391C7.89744 12.3562 7.89741 11.759 8.30762 11.5762L9.02539 11.2568C10.0385 10.8062 10.8445 9.97348 11.2764 8.93066L11.5293 8.31934ZM12 2C15.2723 2 18.1762 3.57144 20 5.99902V3.5H22V9.5H16V7.5H18.6152C17.1746 5.38634 14.7485 4 12 4C7.58172 4 4 7.58172 4 12H2C2 6.47715 6.47715 2 12 2Z" fill="currentColor"/>`,
"macbook": `<path d="M4 5V16H20V5H4ZM2 4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V18H2V4.00748ZM1 19H23V21H1V19Z" fill="currentColor"/>`,
"menu-2": `<path d="M3 4H21V6H3V4ZM3 11H15V13H3V11ZM3 18H21V20H3V18Z" fill="currentColor"/>`,
"menu-fold-2": `<path d="M4.40347 3.90332L2.98926 5.31753L6.17124 8.49951L2.98926 11.6815L4.40347 13.0957L8.99967 8.49951L4.40347 3.90332ZM20.9997 19.9995V17.9995H2.99967V19.9995H20.9997ZM20.9997 12.9995V10.9995H11.9997V12.9995H20.9997ZM20.9997 5.99951V3.99951H11.9997V5.99951H20.9997Z" fill="currentColor"/>`,
"menu-search": `<path d="M15.5 5C13.567 5 12 6.567 12 8.5C12 10.433 13.567 12 15.5 12C17.433 12 19 10.433 19 8.5C19 6.567 17.433 5 15.5 5ZM10 8.5C10 5.46243 12.4624 3 15.5 3C18.5376 3 21 5.46243 21 8.5C21 9.6575 20.6424 10.7315 20.0317 11.6175L22.7071 14.2929L21.2929 15.7071L18.6175 13.0317C17.7315 13.6424 16.6575 14 15.5 14C12.4624 14 10 11.5376 10 8.5ZM3 4H8V6H3V4ZM3 11H8V13H3V11ZM21 18V20H3V18H21Z" fill="currentColor"/>`,
"message-2": `<path d="M6.45455 19L2 22.5V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V18C22 18.5523 21.5523 19 21 19H6.45455ZM5.76282 17H20V5H4V18.3851L5.76282 17ZM11 10H13V12H11V10ZM7 10H9V12H7V10ZM15 10H17V12H15V10Z" fill="currentColor"/>`,
+107 -3
View File
@@ -77,7 +77,7 @@ type HeaderIconActionButtonProps = {
visible?: boolean;
title: string;
ariaLabel: string;
onClick: () => void;
onClick: React.MouseEventHandler<HTMLButtonElement>;
className?: string;
Icon: IconName;
iconClassName?: string;
@@ -115,6 +115,83 @@ const HeaderIconActionButton = React.memo(function HeaderIconActionButton({
);
});
type WindowsWindowControlsProps = {
visible: boolean;
};
const WindowsWindowControls = React.memo(function WindowsWindowControls({ visible }: WindowsWindowControlsProps) {
const { t } = useI18n();
const [isMaximized, setIsMaximized] = React.useState(false);
useEffect(() => {
if (!visible) {
return;
}
let disposed = false;
void invokeDesktop<{ maximized?: boolean }>('desktop_get_current_window_state')
.then((state) => {
if (!disposed) {
setIsMaximized(Boolean(state?.maximized));
}
})
.catch(() => {});
const handleMaximizedChange = (event: Event) => {
const detail = (event as CustomEvent<{ maximized?: boolean }>).detail;
setIsMaximized(Boolean(detail?.maximized));
};
window.addEventListener('openchamber:window-maximized-changed', handleMaximizedChange);
return () => {
disposed = true;
window.removeEventListener('openchamber:window-maximized-changed', handleMaximizedChange);
};
}, [visible]);
if (!visible) {
return null;
}
const buttonClassName = 'app-region-no-drag inline-flex h-12 w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary';
return (
<div className="app-region-no-drag -mr-3 ml-2 flex h-12 shrink-0 items-center" aria-label={t('header.windowControls.groupAria')}>
<button
type="button"
className={buttonClassName}
onClick={() => { void invokeDesktop('desktop_minimize_current_window'); }}
title={t('header.windowControls.minimize')}
aria-label={t('header.windowControls.minimize')}
>
<Icon name="subtract" className="h-4 w-4" />
</button>
<button
type="button"
className={buttonClassName}
onClick={() => {
void invokeDesktop<{ maximized?: boolean }>('desktop_toggle_current_window_maximized')
.then((state) => setIsMaximized(Boolean(state?.maximized)))
.catch(() => {});
}}
title={isMaximized ? t('header.windowControls.restore') : t('header.windowControls.maximize')}
aria-label={isMaximized ? t('header.windowControls.restore') : t('header.windowControls.maximize')}
>
<Icon name={isMaximized ? 'fullscreen-exit' : 'checkbox-blank'} className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={cn(buttonClassName, 'hover:bg-status-error hover:text-status-error-foreground')}
onClick={() => { void invokeDesktop('desktop_close_current_window'); }}
title={t('header.windowControls.close')}
aria-label={t('header.windowControls.close')}
>
<Icon name="close" className="h-4 w-4" />
</button>
</div>
);
});
type DesktopGitHubControlProps = {
isMobile: boolean;
githubAuthStatus: GitHubAuthStatus | null;
@@ -731,6 +808,13 @@ export const Header: React.FC<HeaderProps> = ({
return /Macintosh|Mac OS X/.test(navigator.userAgent || '');
}, []);
const isWindowsElectronDesktop = React.useMemo(() => {
if (typeof window === 'undefined') {
return false;
}
return Boolean(window.__OPENCHAMBER_ELECTRON__) && window.__OPENCHAMBER_PLATFORM__ === 'win32';
}, []);
const macosMajorVersion = React.useMemo(() => {
if (typeof window === 'undefined') {
return null;
@@ -1262,6 +1346,16 @@ export const Header: React.FC<HeaderProps> = ({
toggleSidebar();
}, [blurActiveElement, isMobile, isSessionSwitcherOpen, setSessionSwitcherOpen, toggleSidebar]);
const handleOpenWindowsAppMenu = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
void invokeDesktop('desktop_show_app_menu', {
x: rect.left,
y: rect.bottom,
}).catch((error) => {
console.warn('[header] failed to open app menu', error);
});
}, []);
const handleOpenDraftMiniChat = React.useCallback(() => {
void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: normalize(openDirectory || activeProject?.path || ''),
@@ -1454,7 +1548,7 @@ export const Header: React.FC<HeaderProps> = ({
}, [isDesktopApp, isMacPlatform, macosMajorVersion]);
const webWindowControlsOverlayStyle = React.useMemo<React.CSSProperties | undefined>(() => {
if (isDesktopApp || isVSCode) {
if ((isDesktopApp && !isWindowsElectronDesktop) || isVSCode) {
return undefined;
}
@@ -1466,7 +1560,7 @@ export const Header: React.FC<HeaderProps> = ({
minHeight: 'max(3rem, var(--oc-wco-titlebar-height, 0px))',
height: 'max(3rem, var(--oc-wco-titlebar-height, 0px))',
};
}, [isDesktopApp, isTabletStandalonePwa, isVSCode]);
}, [isDesktopApp, isTabletStandalonePwa, isVSCode, isWindowsElectronDesktop]);
const updateHeaderHeight = React.useCallback(() => {
if (typeof document === 'undefined') {
@@ -1884,6 +1978,15 @@ export const Header: React.FC<HeaderProps> = ({
role="tablist"
aria-label={t('header.navigation.mainAria')}
>
{isWindowsElectronDesktop ? (
<HeaderIconActionButton
title={t('header.actions.openAppMenu')}
ariaLabel={t('header.actions.openAppMenuAria')}
onClick={handleOpenWindowsAppMenu}
className={`${desktopHeaderIconButtonClass} shrink-0`}
Icon={'menu-2'}
/>
) : null}
<HeaderIconActionButton
title={t('header.actions.openSessionsWithShortcut', { shortcut: shortcutLabel('toggle_sidebar') })}
ariaLabel={t('header.actions.openSessionsAria')}
@@ -1974,6 +2077,7 @@ export const Header: React.FC<HeaderProps> = ({
Icon={'picture-in-picture-2'}
/>
{desktopSidebarActions}
<WindowsWindowControls visible={isWindowsElectronDesktop} />
</div>
</div>
</div>
@@ -319,7 +319,7 @@ const FileRow: React.FC<FileRowProps> = ({
export const SidebarFilesTree: React.FC = () => {
const { t } = useI18n();
const { files, runtime } = useRuntimeAPIs();
const { files } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory() ?? '';
const root = normalizePath(currentDirectory.trim());
const showHidden = useDirectoryShowHidden();
@@ -335,6 +335,7 @@ export const SidebarFilesTree: React.FC = () => {
const [searching, setSearching] = React.useState(false);
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({});
const [loadErrorsByDir, setLoadErrorsByDir] = React.useState<Record<string, string>>({});
const loadedDirsRef = React.useRef<Set<string>>(new Set());
const inFlightDirsRef = React.useRef<Set<string>>(new Set());
@@ -419,7 +420,7 @@ export const SidebarFilesTree: React.FC = () => {
inFlightDirsRef.current.add(normalizedDir);
const respectGitignore = !showGitignored;
const listPromise = runtime.isDesktop
const listPromise = files.listDirectory
? files.listDirectory(normalizedDir, { respectGitignore }).then((result) => result.entries.map((entry) => ({
name: entry.name,
path: entry.path,
@@ -437,25 +438,34 @@ export const SidebarFilesTree: React.FC = () => {
loadedDirsRef.current = new Set(loadedDirsRef.current);
loadedDirsRef.current.add(normalizedDir);
setLoadErrorsByDir((prev) => {
if (!prev[normalizedDir]) return prev;
const next = { ...prev };
delete next[normalizedDir];
return next;
});
setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped }));
})
.catch(() => {
setChildrenByDir((prev) => ({
.catch((error) => {
const message = error instanceof Error ? error.message : String(error ?? '');
console.error('Failed to load sidebar directory:', error);
setLoadErrorsByDir((prev) => ({
...prev,
[normalizedDir]: prev[normalizedDir] ?? [],
[normalizedDir]: message,
}));
})
.finally(() => {
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalizedDir);
});
}, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]);
}, [files, mapDirectoryEntries, showGitignored]);
const refreshRoot = React.useCallback(async () => {
if (!root) return;
loadedDirsRef.current = new Set();
inFlightDirsRef.current = new Set();
setLoadErrorsByDir({});
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
await loadDirectory(root);
@@ -484,6 +494,7 @@ export const SidebarFilesTree: React.FC = () => {
loadedDirsRef.current = new Set();
inFlightDirsRef.current = new Set();
setLoadErrorsByDir({});
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
void loadDirectory(root);
}, [loadDirectory, root, showHidden, showGitignored]);
@@ -807,6 +818,7 @@ export const SidebarFilesTree: React.FC = () => {
}
const hasTree = Boolean(root && childrenByDir[root]);
const rootLoadError = root ? loadErrorsByDir[root] : null;
return (
<section className="flex h-full min-h-0 flex-col overflow-hidden bg-sidebar">
@@ -923,6 +935,14 @@ export const SidebarFilesTree: React.FC = () => {
</li>
);
})
) : rootLoadError ? (
<li className="flex flex-col gap-2 px-2 py-1 typography-meta text-muted-foreground">
<span>{rootLoadError}</span>
<Button variant="outline" size="xs" className="w-fit gap-1.5" onClick={() => void refreshRoot()}>
<Icon name="refresh" className="h-3.5 w-3.5" />
{t('sidebarFilesTree.actions.refreshTitle')}
</Button>
</li>
) : hasTree && root ? (
renderTree(root, 0)
) : (
@@ -36,7 +36,7 @@ const guessLabelFromSource = (value: string) => {
: trimmed.startsWith("git@")
? "ssh"
: "shorthand";
if (urlFormat === 'ssh') {
return `${trimmed.split(":")[1].replace(/\.git$/i, '')}`;
}
@@ -777,7 +777,6 @@ export function SessionGroupSection(props: Props): React.ReactNode {
<TooltipTrigger asChild>
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
<Icon name="git-branch" className="h-3.5 w-3.5 text-muted-foreground"
style={branchIconColor ? { color: branchIconColor } : undefined}/>
</span>
</TooltipTrigger>
@@ -810,7 +809,6 @@ export function SessionGroupSection(props: Props): React.ReactNode {
</Tooltip>
) : (
<Icon name="git-branch" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
style={branchIconColor ? { color: branchIconColor } : undefined}/>
)
) : null}
+38 -8
View File
@@ -290,6 +290,32 @@ const isFileMissingError = (error: unknown): boolean => {
const MAX_VIEW_CHARS = 200_000;
const FILE_EDITOR_AUTO_SAVE_KEY = 'openchamber:files:auto-save-enabled';
type FileLineEnding = '\n' | '\r\n';
const detectFileLineEnding = (content: string): FileLineEnding => {
let crlf = 0;
let lf = 0;
for (let index = 0; index < content.length; index += 1) {
if (content.charCodeAt(index) !== 10) {
continue;
}
if (index > 0 && content.charCodeAt(index - 1) === 13) {
crlf += 1;
} else {
lf += 1;
}
}
return crlf > lf ? '\r\n' : '\n';
};
const normalizeEditorLineEndings = (content: string): string => content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const serializeEditorContent = (content: string, lineEnding: FileLineEnding): string => {
const normalized = normalizeEditorLineEndings(content);
return lineEnding === '\r\n' ? normalized.replace(/\n/g, '\r\n') : normalized;
};
const getInitialAutoSaveEnabled = (): boolean => {
if (typeof window === 'undefined') {
@@ -763,6 +789,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [draftContent, setDraftContent] = React.useState('');
const [isSaving, setIsSaving] = React.useState(false);
const [loadedFileLineEnding, setLoadedFileLineEnding] = React.useState<FileLineEnding>('\n');
const dialogInputRef = React.useRef<HTMLInputElement>(null);
const autoSaveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null);
@@ -1007,7 +1034,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const isCurrentRequest = () => activeDirectoryLoadIdsRef.current.get(normalizedDir) === requestId;
const respectGitignore = !showGitignored;
const listPromise = runtime.isDesktop
const listPromise = files.listDirectory
? files.listDirectory(normalizedDir, { respectGitignore }).then((result) => result.entries.map((entry) => ({
name: entry.name,
path: entry.path,
@@ -1051,7 +1078,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalizedDir);
});
}, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]);
}, [files, mapDirectoryEntries, showGitignored]);
const refreshRoot = React.useCallback(async () => {
if (!root) {
@@ -1446,7 +1473,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
setIsSaving(true);
try {
const result = await files.writeFile(selectedFile.path, draftContent);
const contentToWrite = serializeEditorContent(draftContent, loadedFileLineEnding);
const result = await files.writeFile(selectedFile.path, contentToWrite);
if (!result?.success) {
toast.error(t('filesView.toast.writeFileFailed'));
return false;
@@ -1467,7 +1495,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
} finally {
setIsSaving(false);
}
}, [draftContent, files, isDirty, readFileStat, selectedFile, t]);
}, [draftContent, files, isDirty, loadedFileLineEnding, readFileStat, selectedFile, t]);
React.useEffect(() => {
if (!isDirty) {
@@ -1622,10 +1650,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
if (!isCurrentLoad()) {
return;
}
setFileContent(content);
setDraftContent(content.length > MAX_VIEW_CHARS
? `${content.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
: content);
const editorContent = normalizeEditorLineEndings(content);
setLoadedFileLineEnding(detectFileLineEnding(content));
setFileContent(editorContent);
setDraftContent(editorContent.length > MAX_VIEW_CHARS
? `${editorContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
: editorContent);
setLoadedFilePath(node.path);
void readFileStat(node.path, readOptions)
.then((stat) => {
+14 -54
View File
@@ -1001,6 +1001,14 @@ export const GitView: React.FC = () => {
};
}, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]);
const getPushedRemoteName = (result?: Awaited<ReturnType<typeof git.gitPush>>) => {
return result?.pushed[0]?.remote
|| status?.tracking?.split('/')[0]
|| effectiveRemotes.find((remote) => remote.name === 'origin')?.name
|| effectiveRemotes[0]?.name
|| 'origin';
};
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => {
if (!currentDirectory) return;
setSyncAction(action);
@@ -1035,8 +1043,8 @@ export const GitView: React.FC = () => {
: t('gitView.toast.pulledFilesPlural', { count: result.files.length, name: remote.name })
);
} else if (action === 'push') {
await git.gitPush(currentDirectory);
toast.success(t('gitView.toast.pushedToUpstream'));
const result = await git.gitPush(currentDirectory);
toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) }));
} else if (action === 'sync') {
if (!remote) {
throw new Error('No remote available for sync');
@@ -1073,7 +1081,7 @@ export const GitView: React.FC = () => {
: t('gitView.toast.pulledFilesPlural', { count: pulledFileCount, name: remote.name })
);
} else if (pushedChanges) {
toast.success(t('gitView.toast.pushedToUpstream'));
toast.success(t('gitView.toast.pushedToUpstream', { name: remote.name }));
} else {
toast.success(t('gitView.toast.alreadyUpToDate'));
}
@@ -1150,56 +1158,8 @@ export const GitView: React.FC = () => {
await refreshStatusAndBranches();
if (options.pushAfter) {
setSyncAction('sync');
const trackingRemoteName = status?.tracking?.split('/')[0];
const syncRemote = effectiveRemotes.find((remote) => remote.name === trackingRemoteName) ?? effectiveRemotes[0];
if (!syncRemote) {
throw new Error('No remote available for sync');
}
const trackingPrefix = `${syncRemote.name}/`;
const trackedBranch = status?.tracking?.startsWith(trackingPrefix)
? status.tracking.slice(trackingPrefix.length)
: undefined;
let pulledFileCount = 0;
let pushedChanges = false;
await git.gitFetch(currentDirectory, { remote: syncRemote.name });
const afterFetch = await git.getGitStatus(currentDirectory);
if ((afterFetch.behind ?? 0) > 0) {
const pullResult = await git.gitPull(currentDirectory, {
remote: syncRemote.name,
branch: trackedBranch,
rebase: true,
});
pulledFileCount = pullResult.files.length;
}
const afterPull = await git.getGitStatus(currentDirectory);
if ((afterPull.ahead ?? 0) > 0) {
await git.gitPush(currentDirectory);
pushedChanges = true;
}
if (pulledFileCount > 0 && pushedChanges) {
toast.success(
pulledFileCount === 1
? t('gitView.toast.syncedPulledSingleAndPushed', { count: pulledFileCount, name: syncRemote.name })
: t('gitView.toast.syncedPulledPluralAndPushed', { count: pulledFileCount, name: syncRemote.name })
);
} else if (pulledFileCount > 0) {
toast.success(
pulledFileCount === 1
? t('gitView.toast.pulledFilesSingle', { count: pulledFileCount, name: syncRemote.name })
: t('gitView.toast.pulledFilesPlural', { count: pulledFileCount, name: syncRemote.name })
);
} else if (pushedChanges) {
toast.success(t('gitView.toast.pushedToUpstream'));
} else {
toast.success(t('gitView.toast.alreadyUpToDate'));
}
const result = await git.gitPush(currentDirectory);
toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) }));
triggerFireworks();
await refreshStatusAndBranches(false);
} else {
@@ -2257,7 +2217,7 @@ export const GitView: React.FC = () => {
);
}
if (isLoading && isGitRepo === null) {
if (isGitRepo === null || (isGitRepo === true && !status)) {
return (
<div className="flex h-full items-center justify-center">
<div className="flex items-center gap-2 text-muted-foreground">
@@ -13,7 +13,12 @@ import type { IconName } from "@/components/icon/icons";
import { BranchSelector } from './BranchSelector';
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
import { SyncActions } from './SyncActions';
import type { GitStatus, GitIdentityProfile, GitRemote } from '@/lib/api/types';
import type {
GitStatus,
GitIdentityProfile,
GitRemote,
GitRemoteComparison,
} from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
@@ -178,6 +183,49 @@ export const IdentityDropdown: React.FC<IdentityDropdownProps> = ({
);
};
interface UpstreamStatusPillProps {
comparison: GitRemoteComparison;
trackingBranch: string | null;
tooltipDelayMs?: number;
}
const UpstreamStatusPill: React.FC<UpstreamStatusPillProps> = ({
comparison,
trackingBranch,
tooltipDelayMs = 1000,
}) => {
const { t } = useI18n();
const target = `${comparison.remote}/${comparison.branch}`;
const isSynced = comparison.ahead === 0 && comparison.behind === 0;
const tooltipText = trackingBranch
? t('gitView.header.upstreamTooltipTracking', { target, tracking: trackingBranch })
: t('gitView.header.upstreamTooltip', { target });
return (
<Tooltip delayDuration={tooltipDelayMs}>
<TooltipTrigger asChild>
<div className="inline-flex h-8 max-w-full items-center gap-1.5 rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 typography-micro text-muted-foreground">
<Icon name="git-branch" className="size-3.5 shrink-0" />
<span className="min-w-0 truncate text-foreground/80">{target}</span>
{isSynced ? (
<span className="tabular-nums text-muted-foreground">{t('gitView.header.upstreamSynced')}</span>
) : (
<span className="inline-flex items-center gap-1 tabular-nums">
{comparison.ahead > 0 ? (
<span className="text-[var(--status-info)]">{comparison.ahead}</span>
) : null}
{comparison.behind > 0 ? (
<span className="text-[var(--status-warning)]">{comparison.behind}</span>
) : null}
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{tooltipText}</TooltipContent>
</Tooltip>
);
};
export const GitHeader: React.FC<GitHeaderProps> = ({
status,
localBranches,
@@ -264,13 +312,20 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
/>
);
const upstreamStatusPill = status.upstreamComparison ? (
<UpstreamStatusPill
comparison={status.upstreamComparison}
trackingBranch={status.tracking}
tooltipDelayMs={1000}
/>
) : null;
const identityControl = (
<IdentityDropdown
activeProfile={activeIdentityProfile}
identities={availableIdentities}
onSelect={onSelectIdentity}
isApplying={isApplyingIdentity}
iconOnly={true}
/>
);
@@ -293,7 +348,6 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
onCheckout={onCheckoutBranch}
onCreate={onCreateBranch}
remotes={remotes}
/>
)}
</div>
@@ -317,6 +371,9 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
className="h-full"
/>
</div>
{upstreamStatusPill ? (
<div className="min-w-0 shrink">{upstreamStatusPill}</div>
) : null}
<div className="shrink-0">{syncButtons}</div>
</div>
) : null}