Skip to content
Draft
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
12 changes: 12 additions & 0 deletions apps/emdash-desktop/src/main/core/git/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ export const gitController = createRPCController({
}
},

getMergeBase: async (projectId: string, workspaceId: string, base: GitObjectRef) => {
try {
const env = resolveWorkspace(projectId, workspaceId);
if (!env) return err({ type: 'not_found' as const });
const sha = await env.git.getMergeBase(base);
return ok({ sha });
} catch (e) {
log.error('gitCtrl.getMergeBase failed', { projectId, workspaceId, base, error: e });
return err({ type: 'git_error' as const, message: String(e) });
}
},

getFileAtHead: async (projectId: string, workspaceId: string, filePath: string) => {
try {
const env = resolveWorkspace(projectId, workspaceId);
Expand Down
11 changes: 11 additions & 0 deletions apps/emdash-desktop/src/main/core/git/impl/git-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,17 @@ export class GitService implements GitProvider, IDisposable {
return changes;
}

async getMergeBase(base: GitObjectRef): Promise<string | null> {
const baseStr = toRefString(base);
try {
const { stdout } = await this.ctx.exec('git', ['merge-base', baseStr, 'HEAD']);
const sha = stdout.trim();
return sha === '' ? null : sha;
} catch {
return null;
}
}

async getCommitFiles(commitHash: string): Promise<CommitFile[]> {
const { stdout } = await this.ctx.exec('git', [
'diff-tree',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface WorkspaceGitProvider extends Hookable<WorkspaceGitHooks> {
*/
getWorktreeGitDir(mainDotGitAbs: string): Promise<string>;
getChangedFiles(base: DiffMode | GitObjectRef | MergeBaseRange): Promise<GitChange[]>;
getMergeBase(base: GitObjectRef): Promise<string | null>;

getFileDiff(filePath: string, base?: DiffMode | GitObjectRef): Promise<DiffResult>;
getFileAtHead(filePath: string): Promise<string | null>;
Expand Down
5 changes: 5 additions & 0 deletions apps/emdash-desktop/src/main/core/settings/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,11 @@ export const changesViewModeSchema = z.object({
unstaged: z.enum(['flat', 'tree']),
staged: z.enum(['flat', 'tree']),
pr: z.enum(['flat', 'tree']),
unified: z.enum(['flat', 'tree']),
});

export const changesPanelModeSchema = z.enum(['split', 'unified']);

export const browserPreviewSettingsSchema = z.object({ enabled: z.boolean() });

export const resourceMonitorSettingsSchema = z.object({ enabled: z.boolean() });
Expand All @@ -143,6 +146,7 @@ export const APP_SETTINGS_SCHEMA_MAP = {
browserPreview: browserPreviewSettingsSchema,
resourceMonitor: resourceMonitorSettingsSchema,
changesViewMode: changesViewModeSchema,
changesPanelMode: changesPanelModeSchema,
} as const;

export const appSettingsSchema = z.object({
Expand All @@ -160,4 +164,5 @@ export const appSettingsSchema = z.object({
browserPreview: browserPreviewSettingsSchema,
resourceMonitor: resourceMonitorSettingsSchema,
changesViewMode: changesViewModeSchema,
changesPanelMode: changesPanelModeSchema,
});
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ export const SETTINGS_DEFAULTS = {
unstaged: 'flat' as const,
staged: 'flat' as const,
pr: 'flat' as const,
unified: 'flat' as const,
},
changesPanelMode: 'split' as const,
} satisfies SettingsDefaultsMap;

export function getDefaultForKey<K extends AppSettingsKey>(key: K): AppSettings[K] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import { observer } from 'mobx-react-lite';
import { useWorkspace, useWorkspaceViewModel } from '@renderer/features/tasks/task-view-context';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@renderer/lib/ui/resizable';
import { cn } from '@renderer/utils/utils';
import { SplitUnifiedToggle } from './components/split-unified-toggle';
import { GitStatusSection } from './git-status-section';
import { SECTION_HEADER_HEIGHT, usePanelLayout } from './hooks/use-panel-layout';
import { usePanelMode } from './hooks/use-panel-mode';
import { PullRequestsSection } from './pr-section';
import { StagedSection } from './staged-section';
import { UnifiedSection } from './unified-section';
import { UnstagedSection } from './unstaged-section';

export const ChangesPanel = observer(function ChangesPanel() {
Expand All @@ -25,69 +28,80 @@ export const ChangesPanel = observer(function ChangesPanel() {
spacerRef,
} = usePanelLayout(changesView ?? null);

const { mode: panelMode, setMode: setPanelMode } = usePanelMode();

if (!diffView || !changesView || !workspace.git.hasData) return null;

return (
<div className="flex h-full flex-col">
<ResizablePanelGroup
orientation="vertical"
className="min-h-0 flex-1"
id="changes-panel-group"
disableCursor
>
<ResizablePanel
id="changes-unstaged"
panelRef={unstagedRef}
collapsible
collapsedSize={SECTION_HEADER_HEIGHT}
minSize="150px"
maxSize="100%"
defaultSize="33%"
className={cn('flex flex-col overflow-hidden', panelTransitionClass)}
>
<UnstagedSection />
</ResizablePanel>
<ResizableHandle disabled={!expanded.unstaged || !expanded.staged} {...pointerHandlers} />
<ResizablePanel
id="changes-staged"
panelRef={stagedRef}
collapsible
collapsedSize={SECTION_HEADER_HEIGHT}
minSize="150px"
maxSize="100%"
defaultSize="33%"
className={cn('flex flex-col overflow-hidden', panelTransitionClass)}
<div className="flex h-8 shrink-0 items-center justify-end gap-1 border-b border-border px-2">
<SplitUnifiedToggle value={panelMode} onChange={setPanelMode} />
</div>
{panelMode === 'split' ? (
<ResizablePanelGroup
orientation="vertical"
className="min-h-0 flex-1"
id="changes-panel-group"
disableCursor
>
<StagedSection />
</ResizablePanel>
<ResizableHandle
disabled={!expanded.staged || !expanded.pullRequests}
{...pointerHandlers}
/>
<ResizablePanel
id="changes-pr"
panelRef={prRef}
collapsible
collapsedSize={SECTION_HEADER_HEIGHT}
minSize="150px"
maxSize="100%"
defaultSize="33%"
className={cn('flex flex-col overflow-hidden', panelTransitionClass)}
>
<PullRequestsSection
onToggleCollapsed={() => toggleExpanded('pullRequests')}
collapsed={!expanded.pullRequests}
<ResizablePanel
id="changes-unstaged"
panelRef={unstagedRef}
collapsible
collapsedSize={SECTION_HEADER_HEIGHT}
minSize="150px"
maxSize="100%"
defaultSize="33%"
className={cn('flex flex-col overflow-hidden', panelTransitionClass)}
>
<UnstagedSection />
</ResizablePanel>
<ResizableHandle disabled={!expanded.unstaged || !expanded.staged} {...pointerHandlers} />
<ResizablePanel
id="changes-staged"
panelRef={stagedRef}
collapsible
collapsedSize={SECTION_HEADER_HEIGHT}
minSize="150px"
maxSize="100%"
defaultSize="33%"
className={cn('flex flex-col overflow-hidden', panelTransitionClass)}
>
<StagedSection />
</ResizablePanel>
<ResizableHandle
disabled={!expanded.staged || !expanded.pullRequests}
{...pointerHandlers}
/>
<ResizablePanel
id="changes-pr"
panelRef={prRef}
collapsible
collapsedSize={SECTION_HEADER_HEIGHT}
minSize="150px"
maxSize="100%"
defaultSize="33%"
className={cn('flex flex-col overflow-hidden', panelTransitionClass)}
>
<PullRequestsSection
onToggleCollapsed={() => toggleExpanded('pullRequests')}
collapsed={!expanded.pullRequests}
/>
</ResizablePanel>
<ResizablePanel
id="changes-spacer"
panelRef={spacerRef}
minSize="0%"
maxSize="100%"
defaultSize="0%"
className="border-t border-border"
/>
</ResizablePanel>
<ResizablePanel
id="changes-spacer"
panelRef={spacerRef}
minSize="0%"
maxSize="100%"
defaultSize="0%"
className="border-t border-border"
/>
</ResizablePanelGroup>
</ResizablePanelGroup>
) : (
<div className="flex min-h-0 flex-1 flex-col">
<UnifiedSection />
</div>
)}
<GitStatusSection />
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Layers, Rows3 } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@renderer/lib/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/lib/ui/tooltip';
import type { ChangesPanelMode } from '@shared/core/app-settings';

interface SplitUnifiedToggleProps {
value: ChangesPanelMode;
onChange: (mode: ChangesPanelMode) => void;
}

export function SplitUnifiedToggle({ value, onChange }: SplitUnifiedToggleProps) {
const next: ChangesPanelMode = value === 'split' ? 'unified' : 'split';
const Icon = value === 'split' ? Layers : Rows3;
const tooltip = value === 'split' ? 'Switch to unified view' : 'Switch to split view';
const [open, setOpen] = useState(false);

return (
<Tooltip
open={open}
onOpenChange={(nextOpen, details) => {
if (!nextOpen && details.reason === 'trigger-press') return;
setOpen(nextOpen);
}}
>
<TooltipTrigger>
<Button variant="ghost" size="icon-xs" onClick={() => onChange(next)} aria-label={tooltip}>
<Icon className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useAppSettingsKey } from '@renderer/features/settings/use-app-settings-key';
import type { ChangesPanelMode } from '@shared/core/app-settings';

export function usePanelMode() {
const { value, update } = useAppSettingsKey('changesPanelMode');
const mode: ChangesPanelMode = value ?? 'split';
const setMode = (next: ChangesPanelMode) => update(next as never);
return { mode, setMode };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { observer } from 'mobx-react-lite';
import { useWorkspace, useWorkspaceViewModel } from '@renderer/features/tasks/task-view-context';
import { EmptyState } from '@renderer/lib/ui/empty-state';
import { commitRef, type GitChange } from '@shared/core/git/git';
import { ChangesListOrTree } from './components/changes-list-or-tree';
import { ChangesViewModeToggle } from './components/changes-view-mode-toggle';
import { SectionHeader } from './components/section-header';
import { useChangesViewMode } from './hooks/use-changes-view-mode';

export const UnifiedSection = observer(function UnifiedSection() {
const taskView = useWorkspaceViewModel();
const workspace = useWorkspace();
const diffView = taskView.diffView;
const unified = diffView?.unifiedChanges;

const { mode: viewMode, setMode: setViewMode } = useChangesViewMode('unified');

if (!diffView || !unified || !workspace.git.hasData) return null;

const mergeBaseSha = unified.mergeBase.data;
const changes = unified.changes.data ?? [];
const hasChanges = changes.length > 0;
const isLoading = unified.changes.loading || unified.mergeBase.loading;
const baseUnresolvable = unified.baseRef === null;
const noMergeBase = !mergeBaseSha && unified.baseRef !== null && !isLoading;

const activePath =
taskView.tabManager.activeDescriptor?.kind === 'diff' &&
taskView.tabManager.activeDescriptor.diffGroup === 'unified'
? taskView.tabManager.activeDescriptor.path
: undefined;

const open = (change: GitChange, preview: boolean) => {
if (!mergeBaseSha) return;
const activeFile = {
path: change.path,
type: 'disk' as const,
group: 'unified' as const,
originalRef: commitRef(mergeBaseSha),
};
if (preview) taskView.tabManager.openDiffPreview(activeFile, change.status);
else taskView.tabManager.openDiff(activeFile, change.status);
};

return (
<div className="flex h-full flex-col">
<SectionHeader
label="All changes"
count={changes.length}
actions={
<ChangesViewModeToggle value={viewMode} onChange={setViewMode} label="All changes" />
}
/>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{baseUnresolvable && (
<EmptyState
label="No base branch"
description="Configure a default branch in project settings to use this view."
/>
)}
{!baseUnresolvable && noMergeBase && (
<EmptyState
label="No common history"
description="This branch shares no commits with the base. Nothing to compare."
/>
)}
{!baseUnresolvable && !noMergeBase && !isLoading && !hasChanges && (
<EmptyState
label="No changes"
description="Nothing differs between this branch and the base."
/>
)}
<div className="min-h-0 flex-1 px-1">
<ChangesListOrTree
viewMode={viewMode}
changes={changes}
activePath={activePath}
onSelectChange={(c) => open(c, true)}
onDoubleClickChange={(c) => open(c, false)}
/>
</div>
</div>
</div>
);
});
Loading
Loading