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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
cc7969ac00
commit
becd240168
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user