Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 64 additions & 72 deletions src/main/utils/gitStatusParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ function normalizeStatusCode(statusCode: string): string {
return statusCode.padEnd(2, '.').slice(0, 2);
}

function isStagedFromStatusCode(statusCode: string): boolean {
const indexStatus = normalizeStatusCode(statusCode)[0];
return indexStatus !== '.' && indexStatus !== ' ' && indexStatus !== '?';
}

function mapStatusCodeToStatus(
statusCode: string,
rawEntryType?: '1' | '2' | 'u' | '?'
Expand All @@ -25,6 +30,7 @@ function mapStatusCodeToStatus(

const [indexStatus, worktreeStatus] = normalizeStatusCode(statusCode);

// Renamed or copied
if (
rawEntryType === '2' ||
indexStatus === 'R' ||
Expand All @@ -41,12 +47,6 @@ function mapStatusCodeToStatus(
return 'modified';
}

function isStagedFromStatusCode(statusCode: string): boolean {
const normalized = normalizeStatusCode(statusCode);
const indexStatus = normalized[0];
return indexStatus !== '.' && indexStatus !== ' ' && indexStatus !== '?';
}

function parsePorcelainV1Line(line: string): ParsedGitStatusEntry | null {
if (line.length < 3) return null;

Expand Down Expand Up @@ -75,97 +75,93 @@ function parsePorcelainV1Line(line: string): ParsedGitStatusEntry | null {
};
}

/**
* Parse `git status --porcelain=v2 -z` output.
*
* For fallback support, if the payload does not look like porcelain v2 records
* this parser falls back to porcelain v1 line parsing.
*/
export function parseGitStatusOutput(output: string): ParsedGitStatusEntry[] {
const tokens = output.split('\0').filter((token) => token.length > 0);
const entries: ParsedGitStatusEntry[] = [];
function parseV1Entries(output: string): ParsedGitStatusEntry[] {
return output
.split('\n')
.map((line) => line.replace(/\r$/, ''))
.filter((line) => line.length > 0)
.map(parsePorcelainV1Line)
.filter((entry): entry is ParsedGitStatusEntry => entry !== null);
}

const looksLikePorcelainV2 = tokens.some((token) => /^(?:1|2|u|\?|!|#)\s/.test(token));
function createStatusEntry(
path: string,
statusCode: string,
entryType: '1' | '2' | 'u' | '?',
oldPath?: string
): ParsedGitStatusEntry {
return {
path,
statusCode,
status: mapStatusCodeToStatus(statusCode, entryType),
isStaged: isStagedFromStatusCode(statusCode),
...(oldPath ? { oldPath } : {}),
};
}

if (!looksLikePorcelainV2) {
return output
.split('\n')
.map((line) => line.replace(/\r$/, ''))
.filter((line) => line.length > 0)
.map(parsePorcelainV1Line)
.filter((entry): entry is ParsedGitStatusEntry => entry !== null);
}
function parseV2Entries(tokens: string[]): ParsedGitStatusEntry[] {
const entries: ParsedGitStatusEntry[] = [];

for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const entryType = token[0];

if (entryType === '#' || entryType === '!') {
continue;
}
// Skip headers and ignored entries
if (entryType === '#' || entryType === '!') continue;

// Untracked file
if (entryType === '?') {
const path = token.slice(2);
entries.push({
path,
statusCode: '??',
status: 'added',
isStaged: false,
});
entries.push(createStatusEntry(token.slice(2), '??', '?'));
continue;
}

// Regular entry: 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
if (entryType === '1') {
// 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
const fields = token.split(' ');
if (fields.length < 9) continue;
const statusCode = fields[1];
const path = fields.slice(8).join(' ');
entries.push({
path,
statusCode,
status: mapStatusCodeToStatus(statusCode, '1'),
isStaged: isStagedFromStatusCode(statusCode),
});
entries.push(createStatusEntry(fields.slice(8).join(' '), fields[1], '1'));
continue;
}

// Renamed/copied entry: 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>\0<origPath>
if (entryType === '2') {
// 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>\0<origPath>
const fields = token.split(' ');
if (fields.length < 10) continue;
const statusCode = fields[1];
const path = fields.slice(9).join(' ');
const oldPath = tokens[i + 1];
if (oldPath !== undefined) i += 1;
entries.push({
path,
oldPath,
statusCode,
status: mapStatusCodeToStatus(statusCode, '2'),
isStaged: isStagedFromStatusCode(statusCode),
});
entries.push(createStatusEntry(fields.slice(9).join(' '), fields[1], '2', oldPath));
continue;
}

// Unmerged entry: u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
if (entryType === 'u') {
// u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
const fields = token.split(' ');
if (fields.length < 11) continue;
const statusCode = fields[1];
const path = fields.slice(10).join(' ');
entries.push({
path,
statusCode,
status: mapStatusCodeToStatus(statusCode, 'u'),
isStaged: isStagedFromStatusCode(statusCode),
});
entries.push(createStatusEntry(fields.slice(10).join(' '), fields[1], 'u'));
}
}

return entries;
}

/**
* Parse `git status --porcelain=v2 -z` output.
*
* For fallback support, if the payload does not look like porcelain v2 records
* this parser falls back to porcelain v1 line parsing.
*/
export function parseGitStatusOutput(output: string): ParsedGitStatusEntry[] {
const tokens = output.split('\0').filter((token) => token.length > 0);

const looksLikePorcelainV2 = tokens.some((token) => /^(?:1|2|u|\?|!|#)\s/.test(token));

if (!looksLikePorcelainV2) {
return parseV1Entries(output);
}

return parseV2Entries(tokens);
}

function resolveRenamedNumstatPath(filePath: string): string {
if (!filePath.includes(' => ')) return filePath;

Expand All @@ -184,9 +180,9 @@ function parseNumstatValue(value: string): number | null {
return Number.isFinite(parsed) ? parsed : 0;
}

function mergeNumstatValues(left: number | null | undefined, right: number | null): number | null {
function mergeNumstatValues(left: number | null, right: number | null): number | null {
if (left === null || right === null) return null;
return (left ?? 0) + right;
return left + right;
}

/**
Expand All @@ -208,19 +204,15 @@ export function parseNumstatOutput(stdout: string): Map<string, ParsedNumstat> {
const parts = line.split('\t');
if (parts.length < 3) continue;

const additions = parseNumstatValue(parts[0]);
const deletions = parseNumstatValue(parts[1]);
const filePath = resolveRenamedNumstatPath(parts.slice(2).join('\t'));
const current = map.get(filePath);

if (!current) {
map.set(filePath, { additions, deletions });
continue;
}
const additions = parseNumstatValue(parts[0]);
const deletions = parseNumstatValue(parts[1]);

map.set(filePath, {
additions: mergeNumstatValues(current.additions, additions),
deletions: mergeNumstatValues(current.deletions, deletions),
additions: current ? mergeNumstatValues(current.additions, additions) : additions,
deletions: current ? mergeNumstatValues(current.deletions, deletions) : deletions,
});
}

Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/BrowserPreviewSettingsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default function BrowserPreviewSettingsCard() {
const { settings, updateSettings, isLoading, isSaving } = useAppSettings();

return (
<div className="flex items-center justify-between gap-4">
<div id="browser-preview-settings-card" className="flex items-center justify-between gap-4">
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">Show localhost links in browser</span>
<span className="text-sm text-muted-foreground">
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/DefaultAgentSettingsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const DefaultAgentSettingsCard: React.FC = () => {
};

return (
<div className="flex items-center justify-between gap-4">
<div id="default-agent-settings-card" className="flex items-center justify-between gap-4">
<div className="flex flex-1 flex-col gap-0.5">
<p className="text-sm font-medium text-foreground">Default agent</p>
<p className="text-sm text-muted-foreground">
Expand Down
5 changes: 4 additions & 1 deletion src/renderer/components/HiddenToolsSettingsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ export default function HiddenToolsSettingsCard() {
}, [availability, labels]);

return (
<div className="rounded-xl border border-border/60 bg-muted/10 p-2">
<div
id="hidden-tools-settings-card"
className="rounded-xl border border-border/60 bg-muted/10 p-2"
>
<div className="space-y-2">
{sortedApps.map((app) => {
const isDetected = availability[app.id] ?? app.alwaysAvailable ?? false;
Expand Down
56 changes: 27 additions & 29 deletions src/renderer/components/IntegrationsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useModalContext } from '../contexts/ModalProvider';
import GitLabSetupForm from './integrations/GitLabSetupForm';
import ForgejoSetupForm from './integrations/ForgejoSetupForm';
import { GithubDeviceFlowModal } from './GithubDeviceFlowModal';
import { INTEGRATION_REGISTRY, type IntegrationId } from './integrations/registry';

/** Light mode: original SVG colors. Dark / dark-black: primary colour. */
const SvgLogo = ({ raw }: { raw: string }) => {
Expand Down Expand Up @@ -472,21 +473,27 @@ const IntegrationsCard: React.FC = () => {
}
}, []);

const integrations = [
// Runtime state per integration. Static metadata (name/description/order)
// lives in INTEGRATION_REGISTRY so it can be shared with the settings
// search index.
const integrationRuntime: Record<
IntegrationId,
{
id: 'github',
name: 'GitHub',
description: 'Connect your repositories',
logoSvg: string;
connected: boolean;
loading: boolean;
onConnect: () => void;
onDisconnect: () => void;
}
> = {
github: {
logoSvg: githubSvg,
connected: authenticated,
loading: isLoading,
onConnect: handleGithubConnect,
onDisconnect: handleGithubDisconnect,
},
{
id: 'linear',
name: 'Linear',
description: 'Work on Linear tickets',
linear: {
logoSvg: linearSvg,
connected: linearConnected,
loading: linearLoading,
Expand All @@ -496,10 +503,7 @@ const IntegrationsCard: React.FC = () => {
},
onDisconnect: handleLinearDisconnect,
},
{
id: 'jira',
name: 'Jira',
description: 'Work on Jira tickets',
jira: {
logoSvg: jiraSvg,
connected: jiraConnected,
loading: jiraLoading,
Expand All @@ -509,10 +513,7 @@ const IntegrationsCard: React.FC = () => {
},
onDisconnect: handleJiraDisconnect,
},
{
id: 'gitlab',
name: 'GitLab',
description: 'Work on GitLab issues',
gitlab: {
logoSvg: gitlabSvg,
connected: gitlabConnected,
loading: gitlabLoading,
Expand All @@ -522,10 +523,7 @@ const IntegrationsCard: React.FC = () => {
},
onDisconnect: handleGitlabDisconnect,
},
{
id: 'plain',
name: 'Plain',
description: 'Work on support threads',
plain: {
logoSvg: plainSvg,
connected: plainConnected,
loading: plainLoading,
Expand All @@ -535,10 +533,7 @@ const IntegrationsCard: React.FC = () => {
},
onDisconnect: handlePlainDisconnect,
},
{
id: 'forgejo',
name: 'Forgejo',
description: 'Work on Forgejo issues',
forgejo: {
logoSvg: forgejoSvg,
connected: forgejoConnected,
loading: forgejoLoading,
Expand All @@ -548,10 +543,7 @@ const IntegrationsCard: React.FC = () => {
},
onDisconnect: handleForgejoDisconnect,
},
{
id: 'sentry',
name: 'Sentry',
description: 'Fix errors from Sentry',
sentry: {
logoSvg: sentrySvg,
connected: sentryConnected,
loading: sentryLoading,
Expand All @@ -561,11 +553,17 @@ const IntegrationsCard: React.FC = () => {
},
onDisconnect: handleSentryDisconnect,
},
];
};

const integrations = INTEGRATION_REGISTRY.map((entry) => ({
...entry,
...integrationRuntime[entry.id as IntegrationId],
}));

return (
<>
<div
id="integrations-card"
className="grid gap-3"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}
>
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/KeyboardSettingsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ const KeyboardSettingsCard: React.FC = () => {
};

return (
<div className="rounded-xl border border-border/60 bg-muted/10 p-4">
<div id="keyboard-settings-card" className="rounded-xl border border-border/60 bg-muted/10 p-4">
<div className="space-y-4">
{CONFIGURABLE_SHORTCUTS.map((shortcut) => (
<div key={shortcut.id} className="flex items-center justify-between gap-2">
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/NotificationSettingsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const NotificationSettingsCard: React.FC = () => {
};

return (
<div className="flex flex-col gap-4">
<div id="notification-settings-card" className="flex flex-col gap-4">
{/* Master toggle */}
<div className="flex items-center justify-between gap-4">
<div className="flex flex-1 flex-col gap-0.5">
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/RepositorySettingsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const RepositorySettingsCard: React.FC = () => {
}, [repository?.branchPrefix]);

return (
<div className="grid gap-8">
<div id="repository-settings-card" className="grid gap-8">
<div className="grid gap-2">
<Input
defaultValue={repository?.branchPrefix ?? DEFAULTS.branchPrefix}
Expand Down
Loading
Loading