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
62 changes: 33 additions & 29 deletions src/renderer/src/components/ChangesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { invoke, isMac } from '../api';
import * as actions from '../state/actions';
import { openDialog, patchChanges, store, useAppStore } from '../state/store';
import { liveFindings, SEVERITY_ICON, SEVERITY_TONE } from './review/ReviewView';
import { onListKeyDown } from '../lib/listKeys';
import { Avatar, Button, Checkbox, Icon, PathLabel, Spinner, openContextMenu, statusIcon, statusLabel, type MenuItem } from './ui';

const SUMMARY_LIMIT = 72;
Expand Down Expand Up @@ -81,7 +82,7 @@ export function ChangesTab(): React.JSX.Element {
</span>
) : null}
</div>
<div className="file-list" onContextMenu={(e) => { if ((e.target as HTMLElement).closest('.file-row')) return; if (files.length) openContextMenu(e, [{ label: 'Discard all changes…', danger: true, onClick: () => actions.requestDiscard(files.map((f) => f.path), true) }, { label: 'Stash all changes', onClick: () => void actions.stashAll() }]); }}>
<div className="file-list" role="listbox" aria-multiselectable aria-label="Changed files" onKeyDown={onListKeyDown} onContextMenu={(e) => { if ((e.target as HTMLElement).closest('.file-row')) return; if (files.length) openContextMenu(e, [{ label: 'Discard all changes…', danger: true, onClick: () => actions.requestDiscard(files.map((f) => f.path), true) }, { label: 'Stash all changes', onClick: () => void actions.stashAll() }]); }}>
{status && files.length === 0 ? (
<div className="empty-state" style={{ padding: 24 }}>
<Icon name="check-circle" size={28} />
Expand All @@ -95,6 +96,9 @@ export function ChangesTab(): React.JSX.Element {
return (
<div
key={file.path}
tabIndex={0}
role="option"
aria-selected={selected}
className={`file-row ${selected ? 'selected' : ''} ${selected && !focused ? 'inactive' : ''}`}
onClick={(e) => actions.selectWorkingFile(file.path, { toggle: e.ctrlKey || e.metaKey, range: e.shiftKey })}
onContextMenu={(e) => contextMenu(e, file)}
Expand Down Expand Up @@ -201,7 +205,7 @@ function PrecommitFindingRow({ finding, stale, active }: { finding: ReviewFindin

export function CommitFileRow({ file, selected, onSelect, onContextMenu }: { file: CommitFile; selected: boolean; onSelect: () => void; onContextMenu?: (e: React.MouseEvent) => void }): React.JSX.Element {
return (
<div className={`file-row ${selected ? 'selected' : ''}`} onClick={onSelect} onContextMenu={onContextMenu}>
<div tabIndex={0} role="option" aria-selected={selected} className={`file-row ${selected ? 'selected' : ''}`} onClick={onSelect} onContextMenu={onContextMenu}>
<PathLabel path={file.path} />
{file.lfs ? <Icon name="download" size={12} className="muted" title="Git LFS pointer" /> : null}
{file.additions !== null || file.deletions !== null ? (
Expand Down Expand Up @@ -299,33 +303,6 @@ function CommitForm(): React.JSX.Element {
spellCheck
autoComplete="off"
/>
{willSign ? <Icon name="lock" size={14} className="muted" title="Commits are signed" /> : null}
{settings?.ai.provider !== 'disabled' ? (
<Button variant="ghost" iconOnly icon="sparkle" className="sparkle" loading={aiCommitBusy} title="Generate commit message with AI" onClick={() => void actions.generateCommitMessage()} disabled={included.length === 0 || changes.committing} />
) : null}
{settings?.ai.provider !== 'disabled' ? (
<Button variant="ghost" iconOnly icon="eye" loading={precommitReview.running} title="Review changes with AI before committing" onClick={() => void actions.reviewChangesBeforeCommit()} disabled={included.length === 0 || changes.committing} />
) : null}
{actions.splitEntryVisible() ? (
<Button
variant="ghost"
iconOnly
icon="kebab"
title="More AI actions"
disabled={changes.committing}
onClick={(e) =>
openContextMenu(e, [
{
label: 'Split into commits with AI…',
icon: 'sparkle',
disabled: !actions.splitEntryEnabled(),
title: actions.splitEntryEnabled() ? undefined : 'At least two changed files are needed to split into commits.',
onClick: () => actions.openSplitDialog(),
},
])
}
/>
) : null}
</div>
{summaryTooLong && settings?.showCommitLengthWarning !== false ? (
<span className="length-warning">
Expand Down Expand Up @@ -366,6 +343,33 @@ function CommitForm(): React.JSX.Element {
<div className="form-actions">
<div className="left">
<Button variant="ghost" size="sm" icon="person" title={changes.showCoAuthors ? 'Remove co-authors' : 'Add co-authors'} onClick={() => patchChanges((c) => ({ showCoAuthors: !c.showCoAuthors }))} />
{willSign ? <Icon name="lock" size={14} className="muted" title="Commits are signed" /> : null}
{settings?.ai.provider !== 'disabled' ? (
<Button variant="ghost" iconOnly icon="sparkle" className="sparkle" loading={aiCommitBusy} title="Generate commit message with AI" onClick={() => void actions.generateCommitMessage()} disabled={included.length === 0 || changes.committing} />
) : null}
{settings?.ai.provider !== 'disabled' ? (
<Button variant="ghost" iconOnly icon="eye" loading={precommitReview.running} title="Review changes with AI before committing" onClick={() => void actions.reviewChangesBeforeCommit()} disabled={included.length === 0 || changes.committing} />
) : null}
{actions.splitEntryVisible() ? (
<Button
variant="ghost"
iconOnly
icon="kebab"
title="More AI actions"
disabled={changes.committing}
onClick={(e) =>
openContextMenu(e, [
{
label: 'Split into commits with AI…',
icon: 'sparkle',
disabled: !actions.splitEntryEnabled(),
title: actions.splitEntryEnabled() ? undefined : 'At least two changed files are needed to split into commits.',
onClick: () => actions.openSplitDialog(),
},
])
}
/>
) : null}
{repo?.github ? <Button variant="ghost" size="sm" icon="issue" title="Reference an issue" onClick={() => actions.openIssuesDialog()}>#</Button> : null}
<Checkbox checked={changes.amend} onChange={(v) => void actions.setAmend(v)} label="Amend last commit" disabled={changes.committing || !!status?.branch.unborn || inMerge} />
</div>
Expand Down
9 changes: 7 additions & 2 deletions src/renderer/src/components/HealthView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,22 @@ function LargeFilesCard(): React.JSX.Element {
const [status, setStatus] = useState<CardStatus>('idle');
const [error, setError] = useState<string | null>(null);
const [blobs, setBlobs] = useState<LargeBlob[]>([]);
const [cancelled, setCancelled] = useState(false);
const [lfsInstalled, setLfsInstalled] = useState(false);

const load = async (): Promise<void> => {
if (!repo) return;
setStatus('loading');
setCancelled(false);
try {
const [result, tools] = await Promise.all([invoke('repo.health.largeFiles', repo.path, LARGE_FILE_LIMIT), invoke('app.tools', false)]);
setBlobs(result);
setLfsInstalled(tools.gitLfs.installed);
setStatus('idle');
} catch (err) {
// A cancelled scan returns to idle with no partial results, rather than showing an error.
// A cancelled scan returns to idle, keeping whatever results the previous scan found.
if (errorInfo(err).code === 'cancelled') {
setBlobs([]);
setCancelled(true);
setStatus('idle');
} else {
setError(errorMessage(err));
Expand All @@ -100,6 +102,7 @@ function LargeFilesCard(): React.JSX.Element {
};

useEffect(() => {
setBlobs([]);
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [repo?.path]);
Expand All @@ -111,6 +114,8 @@ function LargeFilesCard(): React.JSX.Element {
<Spinner /> Scanning history…
<Button size="sm" variant="ghost" onClick={() => void invoke('app.operations.cancel', progress.id)}>Cancel</Button>
</div>
) : cancelled && !blobs.length ? (
<p className="muted">Scan cancelled.</p>
) : !blobs.length ? (
<p className="muted">No large blobs found in history.</p>
) : (
Expand Down
36 changes: 32 additions & 4 deletions src/renderer/src/components/HistoryTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as actions from '../state/actions';
import { historyFilterActive, historyReorderDisabled } from '../state/actions';
import { openDialog, patchHistory, setPopover, store, useAppStore } from '../state/store';
import { CommitFileRow } from './ChangesTab';
import { onListKeyDown } from '../lib/listKeys';
import { Avatar, Badge, Button, Checkbox, FilterInput, Icon, RelativeTime, Spinner, TextField, openContextMenu, type IconName, type MenuItem } from './ui';

const SIGNATURE_BADGES: Partial<Record<SignatureStatus, { icon: IconName; className: string; label: (signer: string | null) => string }>> = {
Expand Down Expand Up @@ -133,6 +134,10 @@ export function HistoryTab(): React.JSX.Element {
) : null}
<div
className="commit-list"
role="listbox"
aria-multiselectable
aria-label="Commits"
onKeyDown={onListKeyDown}
onDragOver={(e) => {
if (history.dragging) {
e.preventDefault();
Expand All @@ -148,7 +153,24 @@ export function HistoryTab(): React.JSX.Element {
<Spinner /> Loading history…
</div>
) : null}
{!history.loading && !history.commits.length ? <div className="list-empty">{filterActive ? 'No commits match your search.' : status?.branch.unborn ? 'No commits yet.' : 'No history to show.'}</div> : null}
{!history.loading && !history.commits.length ? (
<div className="list-empty">
{history.error ? (
<>
{history.error}
<div>
<Button size="sm" onClick={() => void actions.loadHistory(true)}>Retry</Button>
</div>
</>
) : filterActive ? (
'No commits match your search.'
) : status?.branch.unborn ? (
'No commits yet.'
) : (
'No history to show.'
)}
</div>
) : null}
{dragOver === 'top' && history.dragging ? <div style={{ height: 2, background: 'var(--accent)' }} /> : null}
{history.commits.map((c, index) => {
const selected = history.selectedShas.includes(c.sha);
Expand All @@ -158,6 +180,9 @@ export function HistoryTab(): React.JSX.Element {
return (
<div
key={c.sha}
tabIndex={0}
role="option"
aria-selected={selected}
className={`commit-row ${selected ? 'selected' : ''} ${selected && !focused ? 'inactive' : ''} ${dragOver === c.sha ? 'drag-over' : ''}`}
onClick={(e) => actions.selectCommit(c.sha, { toggle: e.ctrlKey || e.metaKey, range: e.shiftKey })}
onContextMenu={(e) => {
Expand Down Expand Up @@ -307,8 +332,11 @@ export function CommitDetailsPane(): React.JSX.Element {
if (!details) {
return (
<div className="empty-state">
{history.detailsLoading ? <Spinner large /> : <Icon name="history" size={32} />}
<p>{history.detailsLoading ? 'Loading commit…' : 'Select a commit to view its changes.'}</p>
{history.detailsLoading ? <Spinner large /> : <Icon name={history.detailsError ? 'alert' : 'history'} size={32} />}
<p>{history.detailsLoading ? 'Loading commit…' : history.detailsError ?? 'Select a commit to view its changes.'}</p>
{history.detailsError ? (
<Button size="sm" onClick={() => void actions.loadCommitDetails(history.selectedShas[0])}>Retry</Button>
) : null}
</div>
);
}
Expand Down Expand Up @@ -370,7 +398,7 @@ export function CommitDetailsPane(): React.JSX.Element {
{visibleFiles.length} {history.matchingFiles ? 'matching' : 'changed'} file{visibleFiles.length === 1 ? '' : 's'}
</span>
</div>
<div className="file-list">
<div className="file-list" role="listbox" aria-label="Changed files" onKeyDown={onListKeyDown}>
{visibleFiles.map((f) => (
<CommitFileRow
key={f.path}
Expand Down
8 changes: 6 additions & 2 deletions src/renderer/src/components/StashesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Stash } from '@shared/types';
import * as actions from '../state/actions';
import { openDialog, useAppStore } from '../state/store';
import { CommitFileRow } from './ChangesTab';
import { onListKeyDown } from '../lib/listKeys';
import { Button, Icon, RelativeTime, Spinner, openContextMenu, type MenuItem } from './ui';

const FILE_PAGE = 2000;
Expand Down Expand Up @@ -48,7 +49,7 @@ export function StashesView(): React.JSX.Element {
<span style={{ flex: 1 }} />
<Button size="sm" variant="ghost" iconOnly icon="x" title="Back to changes" onClick={() => actions.setView('changes')} />
</div>
<div className="file-list">
<div className="file-list" role="listbox" aria-label="Stashes" onKeyDown={onListKeyDown}>
{view.loading && !stashes.length ? (
<div className="list-empty">
<Spinner /> Loading stashes…
Expand All @@ -64,6 +65,9 @@ export function StashesView(): React.JSX.Element {
{sorted.map((st) => (
<div
key={st.sha}
tabIndex={0}
role="option"
aria-selected={view.selectedSha === st.sha}
className={`list-row ${view.selectedSha === st.sha ? 'selected' : ''}`}
onClick={() => void actions.selectStash(st.sha)}
onContextMenu={(e) => {
Expand All @@ -90,7 +94,7 @@ export function StashesView(): React.JSX.Element {
<div className="changes-header">
<span className="count">{view.files.length} changed file{view.files.length === 1 ? '' : 's'}</span>
</div>
<div className="file-list">
<div className="file-list" role="listbox" aria-label="Changed files" onKeyDown={onListKeyDown}>
{view.filesLoading ? (
<div className="list-empty">
<Spinner />
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/src/components/Welcome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Button, Icon, RelativeTime } from './ui';
export function Welcome(): React.JSX.Element {
const repos = useAppStore((s) => s.repos);
const work = useAppStore((s) => s.work);
const workError = useAppStore((s) => s.workError);
const account = useAppStore((s) => s.tools?.ghAccount ?? null);
const recent = [...repos].sort((a, b) => b.lastOpened - a.lastOpened).slice(0, 6);

Expand Down Expand Up @@ -77,6 +78,12 @@ export function Welcome(): React.JSX.Element {
</div>
))}
</div>
) : workError ? (
<div className="welcome-recent">
<h3>Repository health</h3>
<p className="muted">{workError}</p>
<Button size="sm" onClick={() => void actions.loadWork()}>Retry</Button>
</div>
) : null}
</div>
</div>
Expand Down
Loading
Loading