Merge remote-tracking branch 'origin/main' into feat/nested-git-repos
# Conflicts: # packages/web/server/lib/fs/routes.test.js
This commit is contained in:
@@ -26,6 +26,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type OperationType = 'merge' | 'rebase';
|
||||
@@ -94,22 +95,19 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
|
||||
// Filter branches based on search
|
||||
const filteredLocal = React.useMemo(() => {
|
||||
const term = branchSearch.toLowerCase();
|
||||
const remoteBranchNames = new Set(
|
||||
remoteBranches
|
||||
.map((branch) => branch.slice(branch.indexOf('/') + 1))
|
||||
.filter(Boolean)
|
||||
);
|
||||
const filtered = localBranches.filter((branch) => branch !== currentBranch && !remoteBranchNames.has(branch));
|
||||
if (!term) return filtered;
|
||||
return filtered.filter((b) => b.toLowerCase().includes(term));
|
||||
const candidates = localBranches.filter((branch) => branch !== currentBranch && !remoteBranchNames.has(branch));
|
||||
return rankByQuery(candidates, branchSearch, (branch) => [branch]);
|
||||
}, [branchSearch, localBranches, currentBranch, remoteBranches]);
|
||||
|
||||
const filteredRemote = React.useMemo(() => {
|
||||
const term = branchSearch.toLowerCase();
|
||||
if (!term) return remoteBranches;
|
||||
return remoteBranches.filter((b) => b.toLowerCase().includes(term));
|
||||
}, [branchSearch, remoteBranches]);
|
||||
const filteredRemote = React.useMemo(
|
||||
() => rankByQuery(remoteBranches, branchSearch, (branch) => [branch]),
|
||||
[branchSearch, remoteBranches]
|
||||
);
|
||||
|
||||
const resolveDefaultBranch = React.useCallback(() => {
|
||||
if (!defaultTargetBranch) return null;
|
||||
@@ -321,7 +319,8 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
sideOffset={6}
|
||||
className="w-[var(--anchor-width)] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden"
|
||||
>
|
||||
<Command className="h-full min-h-0">
|
||||
{/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */}
|
||||
<Command className="h-full min-h-0" shouldFilter={false}>
|
||||
<CommandInput
|
||||
ref={searchInputRef}
|
||||
placeholder={t('gitView.branch.searchPlaceholder')}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { GitRemote } from '@/lib/api/types';
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface BranchInfo {
|
||||
@@ -78,17 +79,15 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
||||
[newBranchName]
|
||||
);
|
||||
|
||||
const filteredLocal = React.useMemo(() => {
|
||||
const term = search.toLowerCase();
|
||||
if (!term) return localBranches;
|
||||
return localBranches.filter((b) => b.toLowerCase().includes(term));
|
||||
}, [search, localBranches]);
|
||||
const filteredLocal = React.useMemo(
|
||||
() => rankByQuery(localBranches, search, (branch) => [branch]),
|
||||
[search, localBranches]
|
||||
);
|
||||
|
||||
const filteredRemote = React.useMemo(() => {
|
||||
const term = search.toLowerCase();
|
||||
if (!term) return remoteBranches;
|
||||
return remoteBranches.filter((b) => b.toLowerCase().includes(term));
|
||||
}, [search, remoteBranches]);
|
||||
const filteredRemote = React.useMemo(
|
||||
() => rankByQuery(remoteBranches, search, (branch) => [branch]),
|
||||
[search, remoteBranches]
|
||||
);
|
||||
|
||||
const handleCheckout = (branch: string) => {
|
||||
if (branch === currentBranch) {
|
||||
@@ -184,7 +183,9 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenuContent align="start" className="w-72 p-0 max-h-[60vh] flex flex-col">
|
||||
<Command className="h-full min-h-0">
|
||||
{/* Filtering and ordering are owned by rankByQuery above; cmdk's own
|
||||
filter would re-filter and reorder the already-ranked rows. */}
|
||||
<Command className="h-full min-h-0" shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t('gitView.branch.searchPlaceholder')}
|
||||
value={search}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Button } from '@/components/ui/button';
|
||||
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { getConflictDetails, type MergeConflictDetails } from '@/lib/gitApi';
|
||||
@@ -41,7 +40,6 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
||||
const setPendingSyntheticParts = useInputStore((state) => state.setPendingSyntheticParts);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [conflictDetails, setConflictDetails] = React.useState<MergeConflictDetails | null>(null);
|
||||
@@ -137,7 +135,6 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
|
||||
{ text: context.payloadText, synthetic: true },
|
||||
]);
|
||||
|
||||
setActiveMainTab('chat');
|
||||
onClearState?.();
|
||||
onOpenChange(false);
|
||||
};
|
||||
@@ -159,7 +156,6 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
|
||||
],
|
||||
});
|
||||
// Navigate to chat tab so user sees the new session
|
||||
setActiveMainTab('chat');
|
||||
onClearState?.();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { getGitCommitSummaries } from '@/lib/gitApi';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import {
|
||||
@@ -64,10 +64,15 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
|
||||
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
|
||||
const [branchSearch, setBranchSearch] = React.useState('');
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const filteredBranches = React.useMemo(
|
||||
() => rankByQuery(localBranches, branchSearch, (branch) => [branch]),
|
||||
[localBranches, branchSearch]
|
||||
);
|
||||
|
||||
const [targetBranch, setTargetBranch] = React.useState<string>(defaultTargetBranch);
|
||||
React.useEffect(() => {
|
||||
setTargetBranch(defaultTargetBranch);
|
||||
@@ -228,8 +233,6 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
{ text: context.payloadText, synthetic: true },
|
||||
],
|
||||
});
|
||||
// Navigate to chat tab so user sees the new session
|
||||
setActiveMainTab('chat');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -244,8 +247,7 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
{ text: context.instructionsText, synthetic: true },
|
||||
{ text: context.payloadText, synthetic: true },
|
||||
]);
|
||||
setActiveMainTab('chat');
|
||||
}, [currentSessionId, setActiveMainTab, buildConflictContext, openNewSessionDraft, setPendingInputText, setPendingSyntheticParts, t]);
|
||||
}, [currentSessionId, buildConflictContext, openNewSessionDraft, setPendingInputText, setPendingSyntheticParts, t]);
|
||||
|
||||
const handleMove = React.useCallback(async () => {
|
||||
if (ui.kind !== 'ready') return;
|
||||
@@ -380,10 +382,13 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
align="end"
|
||||
className="w-72 p-0 max-h-[var(--available-height)] flex flex-col overflow-hidden"
|
||||
>
|
||||
<Command className="h-full min-h-0">
|
||||
{/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */}
|
||||
<Command className="h-full min-h-0" shouldFilter={false}>
|
||||
<CommandInput
|
||||
ref={searchInputRef}
|
||||
placeholder={t('gitView.branch.searchPlaceholder')}
|
||||
value={branchSearch}
|
||||
onValueChange={setBranchSearch}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
<CommandList
|
||||
@@ -393,7 +398,7 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
>
|
||||
<CommandEmpty>{t('gitView.branch.empty')}</CommandEmpty>
|
||||
<CommandGroup heading={t('gitView.branch.localBranches')}>
|
||||
{localBranches.map((branch) => (
|
||||
{filteredBranches.map((branch) => (
|
||||
<CommandItem
|
||||
key={branch}
|
||||
value={branch}
|
||||
@@ -401,6 +406,7 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
setTargetBranch(branch);
|
||||
persistTarget(branch);
|
||||
setBranchDropdownOpen(false);
|
||||
setBranchSearch('');
|
||||
}}
|
||||
>
|
||||
{branch}
|
||||
|
||||
@@ -327,7 +327,6 @@ export const PullRequestSection: React.FC<{
|
||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const { isMobile, hasTouchInput, screenWidth } = useDeviceInfo();
|
||||
@@ -508,8 +507,13 @@ export const PullRequestSection: React.FC<{
|
||||
}, [useDetectedUpstream, detectedUpstream?.defaultBranch]);
|
||||
|
||||
const pr = status?.pr ?? null;
|
||||
// A closed/merged PR is the branch's history, not its live status: it still
|
||||
// deserves to be shown (you just merged it), but the branch is free again, so
|
||||
// the panel offers creating the next PR instead of a read-only detail view.
|
||||
const isHistoricalPr = pr?.state === 'merged' || pr?.state === 'closed';
|
||||
const livePr = isHistoricalPr ? null : pr;
|
||||
|
||||
const prContextKey = pr ? getPrContextKey(directory, pr.number) : null;
|
||||
const prContextKey = livePr ? getPrContextKey(directory, livePr.number) : null;
|
||||
const prContextEntry = usePrContextStore((state) => (prContextKey ? state.entries[prContextKey] : undefined));
|
||||
const ensurePrContext = usePrContextStore((state) => state.ensure);
|
||||
const prContext = prContextEntry?.result ?? null;
|
||||
@@ -525,14 +529,14 @@ export const PullRequestSection: React.FC<{
|
||||
|
||||
// Load the context the active segment needs; checks include details.
|
||||
React.useEffect(() => {
|
||||
if (!pr || !github?.prContext || activeSegment === 'overview') {
|
||||
if (!livePr || !github?.prContext || activeSegment === 'overview') {
|
||||
return;
|
||||
}
|
||||
void ensurePrContext(github, directory, pr.number, {
|
||||
void ensurePrContext(github, directory, livePr.number, {
|
||||
includeCheckDetails: activeSegment === 'checks',
|
||||
sourceRepo: status?.repo ?? null,
|
||||
});
|
||||
}, [activeSegment, directory, ensurePrContext, github, pr, status?.repo]);
|
||||
}, [activeSegment, directory, ensurePrContext, github, livePr, status?.repo]);
|
||||
|
||||
const checks = status?.checks ?? null;
|
||||
const checksArePending = (checks?.pending ?? 0) > 0;
|
||||
@@ -981,14 +985,13 @@ export const PullRequestSection: React.FC<{
|
||||
text: '',
|
||||
});
|
||||
}
|
||||
setActiveMainTab('chat');
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast.error(t('gitView.pr.toast.loadChecksFailed'), { description: message });
|
||||
} finally {
|
||||
setIsAttachingChecks(false);
|
||||
}
|
||||
}, [directory, ensurePrContext, github, pr, resolveDraftTarget, setActiveMainTab, status?.repo, t]);
|
||||
}, [directory, ensurePrContext, github, pr, resolveDraftTarget, status?.repo, t]);
|
||||
|
||||
const sendCommentsToChat = React.useCallback(async () => {
|
||||
if (!github?.prContext) {
|
||||
@@ -1016,14 +1019,13 @@ export const PullRequestSection: React.FC<{
|
||||
for (const comment of timelineComments) {
|
||||
attachCommentDraft(target, comment);
|
||||
}
|
||||
setActiveMainTab('chat');
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast.error(t('gitView.pr.toast.loadPrCommentsFailed'), { description: message });
|
||||
} finally {
|
||||
setIsAttachingComments(false);
|
||||
}
|
||||
}, [attachCommentDraft, directory, ensurePrContext, github, pr, resolveDraftTarget, setActiveMainTab, status?.repo, t, timelineComments]);
|
||||
}, [attachCommentDraft, directory, ensurePrContext, github, pr, resolveDraftTarget, status?.repo, t, timelineComments]);
|
||||
|
||||
const sendSingleCommentToChat = React.useCallback(async (comment: TimelineCommentItem) => {
|
||||
const target = resolveDraftTarget();
|
||||
@@ -1032,8 +1034,7 @@ export const PullRequestSection: React.FC<{
|
||||
}
|
||||
|
||||
attachCommentDraft(target, comment);
|
||||
setActiveMainTab('chat');
|
||||
}, [attachCommentDraft, resolveDraftTarget, setActiveMainTab]);
|
||||
}, [attachCommentDraft, resolveDraftTarget]);
|
||||
|
||||
const refresh = React.useCallback(async (options?: { force?: boolean; onlyExistingPr?: boolean; silent?: boolean; markInitialResolved?: boolean }) => {
|
||||
await refreshPrStatus(prStatusKey, options);
|
||||
@@ -1167,31 +1168,29 @@ export const PullRequestSection: React.FC<{
|
||||
}, [remotes, status?.resolvedRemoteName]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const isTerminal = status?.pr?.state === 'closed' || status?.pr?.state === 'merged';
|
||||
const lastRefreshAt = statusEntry?.lastRefreshAt ?? 0;
|
||||
const isStale = Date.now() - lastRefreshAt > 60_000;
|
||||
const shouldRefresh = !isTerminal && isStale;
|
||||
|
||||
const onFocus = () => {
|
||||
if (shouldRefresh) {
|
||||
// Coming back to the app is the moment a PR is most likely to have changed
|
||||
// elsewhere — including a merged one being replaced by a newer open PR — so
|
||||
// staleness is read from the store when the event fires, not captured here.
|
||||
const refreshWhenStale = () => {
|
||||
const lastRefreshAt = useGitHubPrStatusStore.getState().entries[prStatusKey]?.lastRefreshAt ?? 0;
|
||||
if (Date.now() - lastRefreshAt > 60_000) {
|
||||
void refresh({ force: true, silent: true });
|
||||
}
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
if (shouldRefresh) {
|
||||
void refresh({ force: true, silent: true });
|
||||
}
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
refreshWhenStale();
|
||||
};
|
||||
|
||||
window.addEventListener('focus', onFocus);
|
||||
window.addEventListener('focus', refreshWhenStale);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
window.removeEventListener('focus', refreshWhenStale);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
};
|
||||
}, [refresh, status?.pr?.state, statusEntry?.lastRefreshAt]);
|
||||
}, [prStatusKey, refresh]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (githubAuthChecked && githubAuthStatus?.connected === false) {
|
||||
@@ -1454,19 +1453,17 @@ export const PullRequestSection: React.FC<{
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{pr ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-6 shrink-0 items-center justify-center rounded-md border border-border/60 bg-background/70 hover:bg-interactive-hover/60"
|
||||
onClick={() => void openExternal(pr.url)}
|
||||
aria-label={t('gitView.pr.actions.openOnGitHubAria')}
|
||||
>
|
||||
<Icon name={prStateIconName} className="size-4 shrink-0" style={{ color: prColorVar }} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>{t('gitView.pr.actions.openOnGitHub')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={() => void openExternal(pr.url)}
|
||||
aria-label={t('gitView.pr.actions.openOnGitHubAria')}
|
||||
>
|
||||
<Icon name={prStateIconName} className="size-4 shrink-0" style={{ color: prColorVar }} />
|
||||
{t('gitView.pr.actions.openOnGitHub')}
|
||||
</Button>
|
||||
) : (
|
||||
<Icon name={prStateIconName} className="size-4 shrink-0" style={{ color: 'var(--surface-muted-foreground)' }} />
|
||||
)}
|
||||
@@ -1614,7 +1611,7 @@ export const PullRequestSection: React.FC<{
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
{t('gitView.pr.checkingStatus')}
|
||||
</div>
|
||||
) : pr ? (
|
||||
) : pr && !isHistoricalPr ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="h-8 min-w-0">
|
||||
<SortableTabsStrip
|
||||
@@ -1967,6 +1964,30 @@ export const PullRequestSection: React.FC<{
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{pr && isHistoricalPr ? (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border/60 bg-surface-muted/40 px-2.5 py-2">
|
||||
<Icon
|
||||
name={pr.state === 'merged' ? 'git-merge' : 'git-close-pull-request'}
|
||||
className="size-4 shrink-0"
|
||||
style={{ color: prColorVar }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 typography-micro text-muted-foreground">
|
||||
{pr.state === 'merged'
|
||||
? t('gitView.pr.history.merged', { number: pr.number, base: pr.base || targetBaseBranch })
|
||||
: t('gitView.pr.history.closed', { number: pr.number })}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={() => void openExternal(pr.url)}
|
||||
aria-label={t('gitView.pr.actions.openOnGitHubAria')}
|
||||
>
|
||||
<Icon name="external-link" className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-label text-foreground">{t('gitView.pr.createTitle')}</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -69,11 +70,10 @@ export const StashesDialog: React.FC<StashesDialogProps> = ({
|
||||
};
|
||||
}, [directory, open, stashes]);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return stashes;
|
||||
return stashes.filter((stash) => `${stash.ref} ${stash.message} ${stash.relativeTime}`.toLowerCase().includes(normalized));
|
||||
}, [query, stashes]);
|
||||
const filtered = React.useMemo(
|
||||
() => rankByQuery(stashes, query, (stash) => [stash.message, stash.ref, stash.relativeTime]),
|
||||
[query, stashes],
|
||||
);
|
||||
|
||||
const refreshAfterChange = React.useCallback(async (change?: { affectsIndex?: boolean }) => {
|
||||
await load();
|
||||
|
||||
Reference in New Issue
Block a user