feat(ui): add desktop git sidebar + terminal dock and improve in-app PR workflow (#362)
* feat: add unified dropdown with services content in header * feat: add right Git sidebar with resizable panel * feat: implement responsive panel auto-toggle and terminal rehydration - Auto-close the right sidebar when width is below a threshold and auto-open it when space permits - Auto-close the bottom terminal when height is below a threshold and auto-open it when enough space - Apply a dedicated rehydrated streaming configuration for terminal sessions to optimize reconnect behavior * feat: enhance PR view with status caching and annotations * feat(ui): enable chat dispatch in PullRequestSection * feat(TerminalView): adjust layout * feat: refine chat input layout and text selection menu * fix(ui): show empty state in GitView when no changes * feat(git): update PR actions styling and create PR button
This commit is contained in:
committed by
GitHub
parent
3f29b2c6a2
commit
5b0a97d170
@@ -1765,7 +1765,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col relative overflow-visible overflow-hidden",
|
"flex flex-col relative overflow-visible",
|
||||||
"border border-border/80",
|
"border border-border/80",
|
||||||
"focus-within:ring-1 focus-within:ring-primary/50"
|
"focus-within:ring-1 focus-within:ring-primary/50"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,14 +15,18 @@ interface MenuPosition {
|
|||||||
show: boolean;
|
show: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MENU_TRANSITION_MS = 200;
|
||||||
|
|
||||||
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
|
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
|
||||||
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
||||||
const [selectedText, setSelectedText] = React.useState('');
|
const [selectedText, setSelectedText] = React.useState('');
|
||||||
const [isDragging, setIsDragging] = React.useState(false);
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
const [isClosing, setIsClosing] = React.useState(false);
|
const [isClosing, setIsClosing] = React.useState(false);
|
||||||
|
const [isOpening, setIsOpening] = React.useState(false);
|
||||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||||
const pendingSelectionRef = React.useRef<{ text: string; rect: DOMRect } | null>(null);
|
const pendingSelectionRef = React.useRef<{ text: string; rect: DOMRect } | null>(null);
|
||||||
const hideTimeoutRef = React.useRef<number | null>(null);
|
const hideTimeoutRef = React.useRef<number | null>(null);
|
||||||
|
const openRafRef = React.useRef<number | null>(null);
|
||||||
const createSession = useSessionStore((state) => state.createSession);
|
const createSession = useSessionStore((state) => state.createSession);
|
||||||
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
|
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
@@ -33,6 +37,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
window.clearTimeout(hideTimeoutRef.current);
|
window.clearTimeout(hideTimeoutRef.current);
|
||||||
hideTimeoutRef.current = null;
|
hideTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (openRafRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(openRafRef.current);
|
||||||
|
openRafRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -41,6 +49,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
window.clearTimeout(hideTimeoutRef.current);
|
window.clearTimeout(hideTimeoutRef.current);
|
||||||
hideTimeoutRef.current = null;
|
hideTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (openRafRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(openRafRef.current);
|
||||||
|
openRafRef.current = null;
|
||||||
|
}
|
||||||
|
setIsOpening(false);
|
||||||
|
|
||||||
setIsClosing(true);
|
setIsClosing(true);
|
||||||
hideTimeoutRef.current = window.setTimeout(() => {
|
hideTimeoutRef.current = window.setTimeout(() => {
|
||||||
@@ -49,7 +62,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
pendingSelectionRef.current = null;
|
pendingSelectionRef.current = null;
|
||||||
setIsClosing(false);
|
setIsClosing(false);
|
||||||
hideTimeoutRef.current = null;
|
hideTimeoutRef.current = null;
|
||||||
}, 140);
|
}, MENU_TRANSITION_MS);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const showMenu = React.useCallback(() => {
|
const showMenu = React.useCallback(() => {
|
||||||
@@ -62,6 +75,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
setIsClosing(false);
|
setIsClosing(false);
|
||||||
|
|
||||||
const { text, rect } = pendingSelectionRef.current;
|
const { text, rect } = pendingSelectionRef.current;
|
||||||
|
const shouldAnimateIn = !position.show;
|
||||||
|
|
||||||
// Position menu above the selection
|
// Position menu above the selection
|
||||||
const menuX = rect.left + rect.width / 2;
|
const menuX = rect.left + rect.width / 2;
|
||||||
@@ -73,7 +87,18 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
y: menuY,
|
y: menuY,
|
||||||
show: true,
|
show: true,
|
||||||
});
|
});
|
||||||
}, []);
|
|
||||||
|
if (shouldAnimateIn) {
|
||||||
|
setIsOpening(true);
|
||||||
|
if (openRafRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(openRafRef.current);
|
||||||
|
}
|
||||||
|
openRafRef.current = window.requestAnimationFrame(() => {
|
||||||
|
setIsOpening(false);
|
||||||
|
openRafRef.current = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [position.show]);
|
||||||
|
|
||||||
const handleSelectionChange = React.useCallback(() => {
|
const handleSelectionChange = React.useCallback(() => {
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
@@ -221,7 +246,12 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
'bg-[var(--surface-elevated)] border-t border-[var(--interactive-border)]',
|
'bg-[var(--surface-elevated)] border-t border-[var(--interactive-border)]',
|
||||||
'px-3 py-2',
|
'px-3 py-2',
|
||||||
'safe-area-bottom',
|
'safe-area-bottom',
|
||||||
isClosing ? 'animate-out fade-out-0 duration-150 pointer-events-none' : 'animate-in fade-in-0 duration-150'
|
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||||
|
isClosing
|
||||||
|
? 'opacity-0 translate-y-[4px] pointer-events-none'
|
||||||
|
: isOpening
|
||||||
|
? 'opacity-0 translate-y-[4px]'
|
||||||
|
: 'opacity-100 translate-y-0'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
|
paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
|
||||||
@@ -280,18 +310,26 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className={cn(
|
className="fixed z-50"
|
||||||
'fixed z-50 flex items-center gap-1',
|
|
||||||
'rounded-lg border border-[var(--interactive-border)]',
|
|
||||||
'bg-[var(--surface-elevated)] shadow-lg',
|
|
||||||
'px-1.5 py-1',
|
|
||||||
isClosing ? 'animate-out fade-out-0 duration-150 pointer-events-none' : 'animate-in fade-in-0 duration-150'
|
|
||||||
)}
|
|
||||||
style={{
|
style={{
|
||||||
left: position.x,
|
left: position.x,
|
||||||
top: position.y,
|
top: position.y,
|
||||||
transform: 'translate(-50%, -100%)',
|
transform: 'translate(-50%, -100%)',
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1',
|
||||||
|
'rounded-lg border border-[var(--interactive-border)]',
|
||||||
|
'bg-[var(--surface-elevated)] shadow-lg',
|
||||||
|
'px-1.5 py-1',
|
||||||
|
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||||
|
isClosing
|
||||||
|
? 'opacity-0 translate-y-[4px] pointer-events-none'
|
||||||
|
: isOpening
|
||||||
|
? 'opacity-0 translate-y-[4px]'
|
||||||
|
: 'opacity-100 translate-y-0'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddToChat}
|
onClick={handleAddToChat}
|
||||||
@@ -326,6 +364,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
<RiChatNewLine className="h-4 w-4" />
|
<RiChatNewLine className="h-4 w-4" />
|
||||||
<span>New session</span>
|
<span>New session</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
|
RiAddLine,
|
||||||
RiCheckLine,
|
RiCheckLine,
|
||||||
RiCloudOffLine,
|
RiCloudOffLine,
|
||||||
RiEarthLine,
|
RiEarthLine,
|
||||||
@@ -143,9 +144,16 @@ const resolveCurrentHost = (hosts: DesktopHost[]) => {
|
|||||||
type DesktopHostSwitcherDialogProps = {
|
type DesktopHostSwitcherDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
embedded?: boolean;
|
||||||
|
onHostSwitched?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwitcherDialogProps) {
|
export function DesktopHostSwitcherDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
embedded = false,
|
||||||
|
onHostSwitched,
|
||||||
|
}: DesktopHostSwitcherDialogProps) {
|
||||||
const [configHosts, setConfigHosts] = React.useState<DesktopHost[]>([]);
|
const [configHosts, setConfigHosts] = React.useState<DesktopHost[]>([]);
|
||||||
const [defaultHostId, setDefaultHostId] = React.useState<string | null>(null);
|
const [defaultHostId, setDefaultHostId] = React.useState<string | null>(null);
|
||||||
const [statusById, setStatusById] = React.useState<Record<string, HostStatus>>({});
|
const [statusById, setStatusById] = React.useState<Record<string, HostStatus>>({});
|
||||||
@@ -160,6 +168,7 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi
|
|||||||
|
|
||||||
const [newLabel, setNewLabel] = React.useState('');
|
const [newLabel, setNewLabel] = React.useState('');
|
||||||
const [newUrl, setNewUrl] = React.useState('');
|
const [newUrl, setNewUrl] = React.useState('');
|
||||||
|
const [isAddFormOpen, setIsAddFormOpen] = React.useState(!embedded);
|
||||||
|
|
||||||
const allHosts = React.useMemo(() => {
|
const allHosts = React.useMemo(() => {
|
||||||
const local = buildLocalHost();
|
const local = buildLocalHost();
|
||||||
@@ -241,11 +250,12 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi
|
|||||||
setEditUrl('');
|
setEditUrl('');
|
||||||
setNewLabel('');
|
setNewLabel('');
|
||||||
setNewUrl('');
|
setNewUrl('');
|
||||||
|
setIsAddFormOpen(!embedded);
|
||||||
setError('');
|
setError('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [open, refresh]);
|
}, [embedded, open, refresh]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -256,13 +266,14 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi
|
|||||||
const origin = host.id === LOCAL_HOST_ID ? getLocalOrigin() : (normalizeHostUrl(host.url) || '');
|
const origin = host.id === LOCAL_HOST_ID ? getLocalOrigin() : (normalizeHostUrl(host.url) || '');
|
||||||
if (!origin) return;
|
if (!origin) return;
|
||||||
const target = toNavigationUrl(origin);
|
const target = toNavigationUrl(origin);
|
||||||
|
onHostSwitched?.();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
window.location.assign(target);
|
window.location.assign(target);
|
||||||
} catch {
|
} catch {
|
||||||
window.location.href = target;
|
window.location.href = target;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [onHostSwitched]);
|
||||||
|
|
||||||
const beginEdit = React.useCallback((host: DesktopHost) => {
|
const beginEdit = React.useCallback((host: DesktopHost) => {
|
||||||
setEditingId(host.id);
|
setEditingId(host.id);
|
||||||
@@ -309,7 +320,10 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi
|
|||||||
await persist(nextHosts, defaultHostId);
|
await persist(nextHosts, defaultHostId);
|
||||||
setNewLabel('');
|
setNewLabel('');
|
||||||
setNewUrl('');
|
setNewUrl('');
|
||||||
}, [configHosts, defaultHostId, newLabel, newUrl, persist]);
|
if (embedded) {
|
||||||
|
setIsAddFormOpen(false);
|
||||||
|
}
|
||||||
|
}, [configHosts, defaultHostId, embedded, newLabel, newUrl, persist]);
|
||||||
|
|
||||||
const deleteHost = React.useCallback(async (id: string) => {
|
const deleteHost = React.useCallback(async (id: string) => {
|
||||||
if (id === LOCAL_HOST_ID) return;
|
if (id === LOCAL_HOST_ID) return;
|
||||||
@@ -329,9 +343,34 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi
|
|||||||
|
|
||||||
const tauriAvailable = isTauriShell();
|
const tauriAvailable = isTauriShell();
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<>
|
||||||
<DialogContent className="w-[min(42rem,calc(100vw-2rem))] max-w-none max-h-[70vh] flex flex-col overflow-hidden gap-3">
|
{embedded ? (
|
||||||
|
<div className="flex-shrink-0 border-b border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 py-2.5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex items-center gap-2">
|
||||||
|
<span className="typography-ui-header font-semibold text-foreground">Current</span>
|
||||||
|
<span className="max-w-[9rem] truncate typography-ui-label text-muted-foreground">{current.label}</span>
|
||||||
|
<span className="text-muted-foreground">•</span>
|
||||||
|
<span className="typography-ui-header font-semibold text-foreground">Default</span>
|
||||||
|
<span className="max-w-[9rem] truncate typography-ui-label text-muted-foreground">{currentDefaultLabel}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
|
||||||
|
'hover:text-foreground hover:bg-interactive-hover',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
|
||||||
|
)}
|
||||||
|
onClick={() => void probeAll(allHosts)}
|
||||||
|
disabled={!tauriAvailable || isLoading || isProbing}
|
||||||
|
aria-label="Refresh instances"
|
||||||
|
>
|
||||||
|
<RiRefreshLine className={cn('h-4 w-4', isProbing && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<DialogHeader className="flex-shrink-0">
|
<DialogHeader className="flex-shrink-0">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<RiServerLine className="h-5 w-5" />
|
<RiServerLine className="h-5 w-5" />
|
||||||
@@ -341,7 +380,9 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi
|
|||||||
Switch between Local and remote OpenChamber servers
|
Switch between Local and remote OpenChamber servers
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!embedded && (
|
||||||
<div className="flex items-center justify-between gap-2 flex-shrink-0">
|
<div className="flex items-center justify-between gap-2 flex-shrink-0">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="typography-meta text-muted-foreground">Current:</span>
|
<span className="typography-meta text-muted-foreground">Current:</span>
|
||||||
@@ -362,6 +403,7 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!tauriAvailable && (
|
{!tauriAvailable && (
|
||||||
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
|
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
|
||||||
@@ -531,9 +573,39 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
|
{embedded && !isAddFormOpen ? (
|
||||||
|
<div className="flex-shrink-0 border-t border-[var(--interactive-border)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-2 text-left text-muted-foreground hover:text-foreground hover:bg-interactive-hover/30 transition-colors"
|
||||||
|
onClick={() => setIsAddFormOpen(true)}
|
||||||
|
disabled={!tauriAvailable || isSaving}
|
||||||
|
>
|
||||||
|
<RiAddLine className="h-4 w-4" />
|
||||||
|
<span className="typography-ui-label">Add instance</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={cn(
|
||||||
|
'flex-shrink-0',
|
||||||
|
embedded
|
||||||
|
? 'border-t border-[var(--interactive-border)] px-2 py-2'
|
||||||
|
: 'rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-2.5'
|
||||||
|
)}>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="typography-ui-label font-medium text-foreground">Add instance</div>
|
<div className="typography-ui-label font-medium text-foreground">Add instance</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{embedded && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsAddFormOpen(false)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -544,6 +616,7 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi
|
|||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
<Input
|
<Input
|
||||||
value={newLabel}
|
value={newLabel}
|
||||||
@@ -559,10 +632,26 @@ export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwi
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex-shrink-0 typography-meta text-status-error">{error}</div>
|
<div className="flex-shrink-0 typography-meta text-status-error">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<div className="w-full max-h-[70vh] flex flex-col overflow-hidden gap-2">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-[min(42rem,calc(100vw-2rem))] max-w-none max-h-[70vh] flex flex-col overflow-hidden gap-3">
|
||||||
|
{content}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
|
||||||
|
const BOTTOM_DOCK_MIN_HEIGHT = 180;
|
||||||
|
const BOTTOM_DOCK_MAX_HEIGHT = 640;
|
||||||
|
const BOTTOM_DOCK_COLLAPSE_THRESHOLD = 110;
|
||||||
|
|
||||||
|
interface BottomTerminalDockProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen, isMobile, children }) => {
|
||||||
|
const bottomTerminalHeight = useUIStore((state) => state.bottomTerminalHeight);
|
||||||
|
const setBottomTerminalHeight = useUIStore((state) => state.setBottomTerminalHeight);
|
||||||
|
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
|
||||||
|
const [isResizing, setIsResizing] = React.useState(false);
|
||||||
|
const startYRef = React.useRef(0);
|
||||||
|
const startHeightRef = React.useRef(bottomTerminalHeight || 300);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isMobile || !isResizing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
|
const delta = startYRef.current - event.clientY;
|
||||||
|
const nextHeight = Math.min(
|
||||||
|
BOTTOM_DOCK_MAX_HEIGHT,
|
||||||
|
Math.max(BOTTOM_DOCK_MIN_HEIGHT, startHeightRef.current + delta)
|
||||||
|
);
|
||||||
|
setBottomTerminalHeight(nextHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = () => {
|
||||||
|
setIsResizing(false);
|
||||||
|
const latestState = useUIStore.getState();
|
||||||
|
if (latestState.bottomTerminalHeight <= BOTTOM_DOCK_COLLAPSE_THRESHOLD) {
|
||||||
|
setBottomTerminalOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', handlePointerMove);
|
||||||
|
window.addEventListener('pointerup', handlePointerUp, { once: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointermove', handlePointerMove);
|
||||||
|
window.removeEventListener('pointerup', handlePointerUp);
|
||||||
|
};
|
||||||
|
}, [isMobile, isResizing, setBottomTerminalHeight, setBottomTerminalOpen]);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appliedHeight = isOpen
|
||||||
|
? Math.min(BOTTOM_DOCK_MAX_HEIGHT, Math.max(BOTTOM_DOCK_MIN_HEIGHT, bottomTerminalHeight || 300))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const handlePointerDown = (event: React.PointerEvent) => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
setIsResizing(true);
|
||||||
|
startYRef.current = event.clientY;
|
||||||
|
startHeightRef.current = appliedHeight;
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
'relative flex overflow-hidden border-t border-border bg-sidebar',
|
||||||
|
isResizing ? 'transition-none' : 'transition-[height] duration-300 ease-in-out',
|
||||||
|
!isOpen && 'border-t-0'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
height: `${appliedHeight}px`,
|
||||||
|
minHeight: `${appliedHeight}px`,
|
||||||
|
maxHeight: `${appliedHeight}px`,
|
||||||
|
}}
|
||||||
|
aria-hidden={!isOpen || appliedHeight === 0}
|
||||||
|
>
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute left-0 top-0 z-20 h-[4px] w-full cursor-row-resize hover:bg-primary/50 transition-colors',
|
||||||
|
isResizing && 'bg-primary'
|
||||||
|
)}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-label="Resize terminal panel"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative z-10 flex h-full min-h-0 w-full flex-col transition-opacity duration-300 ease-in-out',
|
||||||
|
!isOpen && 'pointer-events-none select-none opacity-0'
|
||||||
|
)}
|
||||||
|
aria-hidden={!isOpen}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,8 +12,9 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { AnimatedTabs } from '@/components/ui/animated-tabs';
|
||||||
|
|
||||||
import { RiArrowLeftSLine, RiChat4Line, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiPlayListAddLine, RiQuestionLine, RiRefreshLine, RiSettings3Line, RiTerminalBoxLine, RiTimerLine, type RemixiconComponentType } from '@remixicon/react';
|
import { RiArrowLeftSLine, RiChat4Line, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiSettings3Line, RiStackLine, RiTerminalBoxLine, RiTimerLine, type RemixiconComponentType } from '@remixicon/react';
|
||||||
import { DiffIcon } from '@/components/icons/DiffIcon';
|
import { DiffIcon } from '@/components/icons/DiffIcon';
|
||||||
import { useUIStore, type MainTab } from '@/stores/useUIStore';
|
import { useUIStore, type MainTab } from '@/stores/useUIStore';
|
||||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||||
@@ -27,7 +28,7 @@ import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
|
|||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { cn, getModifierLabel, hasModifier } from '@/lib/utils';
|
import { cn, getModifierLabel, hasModifier } from '@/lib/utils';
|
||||||
import { useDiffFileCount } from '@/components/views/DiffView';
|
import { useDiffFileCount } from '@/components/views/DiffView';
|
||||||
import { McpDropdown } from '@/components/mcp/McpDropdown';
|
import { McpDropdown, McpDropdownContent } from '@/components/mcp/McpDropdown';
|
||||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||||
import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota';
|
import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota';
|
||||||
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
|
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
|
||||||
@@ -46,7 +47,7 @@ import {
|
|||||||
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react';
|
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react';
|
||||||
import type { UsageWindow } from '@/types';
|
import type { UsageWindow } from '@/types';
|
||||||
import type { GitHubAuthStatus } from '@/lib/api/types';
|
import type { GitHubAuthStatus } from '@/lib/api/types';
|
||||||
import { DesktopHostSwitcherButton } from '@/components/desktop/DesktopHostSwitcher';
|
import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher';
|
||||||
import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
|
import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
|
||||||
import { isDesktopShell } from '@/lib/desktop';
|
import { isDesktopShell } from '@/lib/desktop';
|
||||||
|
|
||||||
@@ -106,9 +107,10 @@ interface TabConfig {
|
|||||||
export const Header: React.FC = () => {
|
export const Header: React.FC = () => {
|
||||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||||
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
|
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
|
||||||
|
const toggleBottomTerminal = useUIStore((state) => state.toggleBottomTerminal);
|
||||||
|
const toggleRightSidebar = useUIStore((state) => state.toggleRightSidebar);
|
||||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||||
const toggleCommandPalette = useUIStore((state) => state.toggleCommandPalette);
|
const toggleCommandPalette = useUIStore((state) => state.toggleCommandPalette);
|
||||||
const toggleHelpDialog = useUIStore((state) => state.toggleHelpDialog);
|
|
||||||
const activeMainTab = useUIStore((state) => state.activeMainTab);
|
const activeMainTab = useUIStore((state) => state.activeMainTab);
|
||||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||||
|
|
||||||
@@ -195,6 +197,16 @@ export const Header: React.FC = () => {
|
|||||||
const githubAccounts = githubAuthStatus?.accounts ?? [];
|
const githubAccounts = githubAuthStatus?.accounts ?? [];
|
||||||
const [isSwitchingGitHubAccount, setIsSwitchingGitHubAccount] = React.useState(false);
|
const [isSwitchingGitHubAccount, setIsSwitchingGitHubAccount] = React.useState(false);
|
||||||
const [isMobileRateLimitsOpen, setIsMobileRateLimitsOpen] = React.useState(false);
|
const [isMobileRateLimitsOpen, setIsMobileRateLimitsOpen] = React.useState(false);
|
||||||
|
const [isDesktopServicesOpen, setIsDesktopServicesOpen] = React.useState(false);
|
||||||
|
const [isUsageRefreshSpinning, setIsUsageRefreshSpinning] = React.useState(false);
|
||||||
|
const [desktopServicesTab, setDesktopServicesTab] = React.useState<'instance' | 'usage' | 'mcp'>(
|
||||||
|
isDesktopApp ? 'instance' : 'usage'
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDesktopApp && desktopServicesTab === 'instance') {
|
||||||
|
setDesktopServicesTab('usage');
|
||||||
|
}
|
||||||
|
}, [desktopServicesTab, isDesktopApp]);
|
||||||
useQuotaAutoRefresh();
|
useQuotaAutoRefresh();
|
||||||
const selectedModels = useQuotaStore((state) => state.selectedModels);
|
const selectedModels = useQuotaStore((state) => state.selectedModels);
|
||||||
const expandedFamilies = useQuotaStore((state) => state.expandedFamilies);
|
const expandedFamilies = useQuotaStore((state) => state.expandedFamilies);
|
||||||
@@ -318,6 +330,15 @@ export const Header: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [setQuotaDisplayMode]);
|
}, [setQuotaDisplayMode]);
|
||||||
|
|
||||||
|
const handleUsageRefresh = React.useCallback(() => {
|
||||||
|
if (isUsageRefreshSpinning) return;
|
||||||
|
setIsUsageRefreshSpinning(true);
|
||||||
|
const minSpinPromise = new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
Promise.all([fetchAllQuotas(), minSpinPromise]).finally(() => {
|
||||||
|
setIsUsageRefreshSpinning(false);
|
||||||
|
});
|
||||||
|
}, [fetchAllQuotas, isUsageRefreshSpinning]);
|
||||||
|
|
||||||
const currentSession = React.useMemo(() => {
|
const currentSession = React.useMemo(() => {
|
||||||
if (!currentSessionId) return null;
|
if (!currentSessionId) return null;
|
||||||
return sessions.find((s) => s.id === currentSessionId) ?? null;
|
return sessions.find((s) => s.id === currentSessionId) ?? null;
|
||||||
@@ -604,18 +625,49 @@ export const Header: React.FC = () => {
|
|||||||
badge: !isMobile && diffFileCount > 0 ? diffFileCount : undefined,
|
badge: !isMobile && diffFileCount > 0 ? diffFileCount : undefined,
|
||||||
},
|
},
|
||||||
{ id: 'files', label: 'Files', icon: RiFolder6Line },
|
{ id: 'files', label: 'Files', icon: RiFolder6Line },
|
||||||
{ id: 'terminal', label: 'Terminal', icon: RiTerminalBoxLine },
|
);
|
||||||
{
|
|
||||||
|
if (isMobile) {
|
||||||
|
base.push({
|
||||||
|
id: 'terminal',
|
||||||
|
label: 'Terminal',
|
||||||
|
icon: RiTerminalBoxLine,
|
||||||
|
}, {
|
||||||
id: 'git',
|
id: 'git',
|
||||||
label: 'Git',
|
label: 'Git',
|
||||||
icon: RiGitBranchLine,
|
icon: RiGitBranchLine,
|
||||||
showDot: isMobile && diffFileCount > 0,
|
showDot: diffFileCount > 0,
|
||||||
},
|
});
|
||||||
);
|
}
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
}, [diffFileCount, isMobile, showPlanTab]);
|
}, [diffFileCount, isMobile, showPlanTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile && (activeMainTab === 'git' || activeMainTab === 'terminal')) {
|
||||||
|
setActiveMainTab('chat');
|
||||||
|
}
|
||||||
|
}, [activeMainTab, isMobile, setActiveMainTab]);
|
||||||
|
|
||||||
|
const servicesTabs = React.useMemo(() => {
|
||||||
|
const base: Array<{ value: 'instance' | 'usage' | 'mcp'; label: string; icon: RemixiconComponentType }> = [];
|
||||||
|
if (isDesktopApp) {
|
||||||
|
base.push({ value: 'instance', label: 'Instance', icon: RiServerLine });
|
||||||
|
}
|
||||||
|
base.push(
|
||||||
|
{ value: 'usage', label: 'Usage', icon: RiTimerLine },
|
||||||
|
{ value: 'mcp', label: 'MCP', icon: RiCommandLine }
|
||||||
|
);
|
||||||
|
return base;
|
||||||
|
}, [isDesktopApp]);
|
||||||
|
|
||||||
|
const quotaDisplayTabs = React.useMemo(() => {
|
||||||
|
return [
|
||||||
|
{ value: 'usage' as const, label: 'Used' },
|
||||||
|
{ value: 'remaining' as const, label: 'Remaining' },
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (hasModifier(e) && !e.shiftKey && !e.altKey) {
|
if (hasModifier(e) && !e.shiftKey && !e.altKey) {
|
||||||
@@ -749,80 +801,77 @@ export const Header: React.FC = () => {
|
|||||||
|
|
||||||
<div className="flex items-center gap-1 pr-3">
|
<div className="flex items-center gap-1 pr-3">
|
||||||
<OpenInAppButton directory={openDirectory} className="mr-1" />
|
<OpenInAppButton directory={openDirectory} className="mr-1" />
|
||||||
{isDesktopApp && (
|
<DropdownMenu
|
||||||
<DesktopHostSwitcherButton headerIconButtonClass={headerIconButtonClass} />
|
open={isDesktopServicesOpen}
|
||||||
)}
|
onOpenChange={(open) => {
|
||||||
<Tooltip delayDuration={500}>
|
setIsDesktopServicesOpen(open);
|
||||||
<TooltipTrigger asChild>
|
if (open && desktopServicesTab === 'usage' && quotaResults.length === 0) {
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleCommandPalette}
|
|
||||||
aria-label="Open command palette"
|
|
||||||
className={headerIconButtonClass}
|
|
||||||
>
|
|
||||||
<RiCommandLine className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Command Palette ({getModifierLabel()}+K)</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<DropdownMenu onOpenChange={(open) => {
|
|
||||||
if (open && quotaResults.length === 0) {
|
|
||||||
fetchAllQuotas();
|
fetchAllQuotas();
|
||||||
}
|
}
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<Tooltip delayDuration={500}>
|
<Tooltip delayDuration={500}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="View rate limits"
|
aria-label="Open instance, usage and MCP"
|
||||||
className={headerIconButtonClass}
|
className={headerIconButtonClass}
|
||||||
disabled={isQuotaLoading}
|
|
||||||
>
|
>
|
||||||
<RiTimerLine className="h-5 w-5" />
|
<RiStackLine className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Rate limits</p>
|
<p>Instance / Usage / MCP</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenuContent align="end" className="w-80 max-h-[70vh] overflow-y-auto overflow-x-hidden p-0">
|
<DropdownMenuContent align="end" className="w-[min(30rem,calc(100vw-2rem))] max-h-[75vh] overflow-hidden p-0">
|
||||||
<div className="sticky top-0 z-20 bg-[var(--surface-elevated)] border-b border-[var(--interactive-border)]">
|
<div className="sticky top-0 z-20 border-b border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-2">
|
||||||
<DropdownMenuLabel className="flex items-center justify-between gap-3 typography-ui-header font-semibold text-foreground">
|
<AnimatedTabs<'instance' | 'usage' | 'mcp'>
|
||||||
<span>Rate limits</span>
|
value={desktopServicesTab}
|
||||||
<div className="flex items-center gap-1">
|
onValueChange={(value) => {
|
||||||
<div className="flex items-center rounded-md border border-[var(--interactive-border)] p-0.5">
|
setDesktopServicesTab(value);
|
||||||
<button
|
if (value === 'usage' && quotaResults.length === 0) {
|
||||||
type="button"
|
fetchAllQuotas();
|
||||||
className={cn(
|
}
|
||||||
'px-2 py-0.5 rounded-sm typography-micro text-[10px] transition-colors',
|
}}
|
||||||
quotaDisplayMode === 'usage'
|
tabs={servicesTabs}
|
||||||
? 'bg-interactive-selection text-interactive-selection-foreground'
|
className="rounded-md"
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
/>
|
||||||
)}
|
|
||||||
onClick={() => handleDisplayModeChange('usage')}
|
|
||||||
aria-label="Show used quota"
|
|
||||||
>
|
|
||||||
Used
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'px-2 py-0.5 rounded-sm typography-micro text-[10px] transition-colors',
|
|
||||||
quotaDisplayMode === 'remaining'
|
|
||||||
? 'bg-interactive-selection text-interactive-selection-foreground'
|
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
|
||||||
)}
|
|
||||||
onClick={() => handleDisplayModeChange('remaining')}
|
|
||||||
aria-label="Show remaining quota"
|
|
||||||
>
|
|
||||||
Remaining
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isDesktopApp && desktopServicesTab === 'instance' && (
|
||||||
|
<DesktopHostSwitcherDialog
|
||||||
|
embedded
|
||||||
|
open={isDesktopServicesOpen && desktopServicesTab === 'instance'}
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
onHostSwitched={() => setIsDesktopServicesOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{desktopServicesTab === 'mcp' && (
|
||||||
|
<McpDropdownContent active={isDesktopServicesOpen && desktopServicesTab === 'mcp'} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{desktopServicesTab === 'usage' && (
|
||||||
|
<div className="max-h-[calc(75vh-3.25rem)] overflow-y-auto overflow-x-hidden">
|
||||||
|
<div className="sticky top-0 z-20 bg-[var(--surface-elevated)] border-b border-[var(--interactive-border)]">
|
||||||
|
<DropdownMenuLabel className="flex items-center justify-between gap-3 py-2.5">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="typography-ui-header font-semibold text-foreground">Rate limits</span>
|
||||||
|
<span className="truncate typography-ui-label text-muted-foreground">
|
||||||
|
Last updated {formatTime(quotaLastUpdated)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<AnimatedTabs<'usage' | 'remaining'>
|
||||||
|
value={quotaDisplayMode}
|
||||||
|
onValueChange={handleDisplayModeChange}
|
||||||
|
tabs={quotaDisplayTabs}
|
||||||
|
size="sm"
|
||||||
|
className="w-[8.25rem]"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -830,20 +879,20 @@ export const Header: React.FC = () => {
|
|||||||
'hover:text-foreground hover:bg-interactive-hover',
|
'hover:text-foreground hover:bg-interactive-hover',
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
|
||||||
)}
|
)}
|
||||||
onClick={() => fetchAllQuotas()}
|
onClick={handleUsageRefresh}
|
||||||
disabled={isQuotaLoading}
|
disabled={isQuotaLoading || isUsageRefreshSpinning}
|
||||||
aria-label="Refresh rate limits"
|
aria-label="Refresh rate limits"
|
||||||
>
|
>
|
||||||
<RiRefreshLine className="h-4 w-4" />
|
<RiRefreshLine className={cn('h-4 w-4', isUsageRefreshSpinning && 'animate-spin')} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<div className="px-2 pb-2 typography-micro text-muted-foreground text-[10px]">
|
|
||||||
Last updated {formatTime(quotaLastUpdated)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{!hasRateLimits && (
|
{!hasRateLimits && (
|
||||||
<DropdownMenuItem className="cursor-default" onSelect={(event) => event.preventDefault()}>
|
<DropdownMenuItem
|
||||||
|
className="cursor-default hover:bg-transparent focus:bg-transparent data-[highlighted]:bg-transparent"
|
||||||
|
onSelect={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
<span className="typography-ui-label text-muted-foreground">No rate limits available.</span>
|
<span className="typography-ui-label text-muted-foreground">No rate limits available.</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
@@ -852,27 +901,25 @@ export const Header: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={group.providerId}>
|
<React.Fragment key={group.providerId}>
|
||||||
<DropdownMenuLabel className="sticky top-[60px] z-10 flex items-center gap-2 bg-[var(--surface-elevated)] typography-ui-label text-foreground">
|
<DropdownMenuLabel className="sticky top-[44px] z-10 flex items-center gap-2 bg-[var(--surface-elevated)] typography-ui-label text-foreground">
|
||||||
<ProviderLogo providerId={group.providerId} className="h-4 w-4" />
|
<ProviderLogo providerId={group.providerId} className="h-4 w-4" />
|
||||||
{group.providerName}
|
{group.providerName}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
{/* Provider-level entries */}
|
|
||||||
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? (
|
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={`${group.providerId}-empty`}
|
key={`${group.providerId}-empty`}
|
||||||
className="cursor-default"
|
className="cursor-default hover:bg-transparent focus:bg-transparent data-[highlighted]:bg-transparent"
|
||||||
onSelect={(event) => event.preventDefault()}
|
onSelect={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
<span className="typography-ui-label text-muted-foreground">No rate limits reported.</span>
|
<span className="typography-ui-label text-muted-foreground">No rate limits reported.</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Provider-level windows */}
|
|
||||||
{group.entries.map(([label, window]) => (
|
{group.entries.map(([label, window]) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={`${group.providerId}-${label}`}
|
key={`${group.providerId}-${label}`}
|
||||||
className="cursor-default items-start"
|
className="cursor-default items-start hover:bg-transparent focus:bg-transparent data-[highlighted]:bg-transparent"
|
||||||
onSelect={(event) => event.preventDefault()}
|
onSelect={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 flex-1 flex-col gap-2">
|
<span className="flex min-w-0 flex-1 flex-col gap-2">
|
||||||
@@ -883,15 +930,19 @@ export const Header: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className="flex min-w-0 items-center justify-between gap-3">
|
<span className="flex min-w-0 items-center justify-between gap-3">
|
||||||
<span className="truncate typography-micro text-muted-foreground">{formatWindowLabel(label)}</span>
|
<span className="min-w-0 flex items-center gap-2">
|
||||||
|
<span className="truncate typography-ui-label text-foreground">{formatWindowLabel(label)}</span>
|
||||||
|
{(window.resetAfterFormatted ?? window.resetAtFormatted) ? (
|
||||||
|
<span className="truncate typography-ui-label text-muted-foreground">
|
||||||
|
{window.resetAfterFormatted ?? window.resetAtFormatted}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
<span className="typography-ui-label text-foreground tabular-nums">
|
<span className="typography-ui-label text-foreground tabular-nums">
|
||||||
{formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)}
|
{formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<UsageProgressBar percent={displayPercent} tonePercent={window.usedPercent} className="h-1" />
|
<UsageProgressBar percent={displayPercent} tonePercent={window.usedPercent} className="h-1.5 mb-1.5" />
|
||||||
<span className="flex items-center justify-between typography-micro text-muted-foreground text-[10px]">
|
|
||||||
<span>{window.resetAfterFormatted ?? window.resetAtFormatted ?? ''}</span>
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -899,7 +950,6 @@ export const Header: React.FC = () => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Model families with collapsible sections - default COLLAPSED */}
|
|
||||||
{group.modelFamilies && group.modelFamilies.length > 0 && (
|
{group.modelFamilies && group.modelFamilies.length > 0 && (
|
||||||
<div className="px-2 py-1">
|
<div className="px-2 py-1">
|
||||||
{group.modelFamilies.map((family) => {
|
{group.modelFamilies.map((family) => {
|
||||||
@@ -947,7 +997,7 @@ export const Header: React.FC = () => {
|
|||||||
? window.remainingPercent
|
? window.remainingPercent
|
||||||
: window.usedPercent;
|
: window.usedPercent;
|
||||||
return (
|
return (
|
||||||
<UsageProgressBar percent={displayPercent} tonePercent={window.usedPercent} className="h-1" />
|
<UsageProgressBar percent={displayPercent} tonePercent={window.usedPercent} className="h-1.5 mb-1.5" />
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
@@ -966,25 +1016,58 @@ export const Header: React.FC = () => {
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<McpDropdown headerIconButtonClass={headerIconButtonClass} />
|
<Tooltip delayDuration={500}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleCommandPalette}
|
||||||
|
aria-label="Open command palette"
|
||||||
|
className={headerIconButtonClass}
|
||||||
|
>
|
||||||
|
<RiCommandLine className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Command Palette ({getModifierLabel()}+K)</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip delayDuration={500}>
|
<Tooltip delayDuration={500}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleHelpDialog}
|
onClick={toggleBottomTerminal}
|
||||||
aria-label="Keyboard shortcuts"
|
aria-label="Toggle terminal panel"
|
||||||
className={headerIconButtonClass}
|
className={headerIconButtonClass}
|
||||||
>
|
>
|
||||||
<RiQuestionLine className="h-5 w-5" />
|
<RiTerminalBoxLine className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Keyboard Shortcuts ({getModifierLabel()}+.)</p>
|
<p>Terminal panel</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip delayDuration={500}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleRightSidebar}
|
||||||
|
aria-label="Toggle git sidebar"
|
||||||
|
className={headerIconButtonClass}
|
||||||
|
>
|
||||||
|
<RiLayoutRightLine className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Git sidebar</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{githubAuthStatus?.connected && !isMobile ? (
|
{githubAuthStatus?.connected && !isMobile ? (
|
||||||
githubAccounts.length > 1 ? (
|
githubAccounts.length > 1 ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
|
import { BottomTerminalDock } from './BottomTerminalDock';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { RightSidebar } from './RightSidebar';
|
||||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||||
import { CommandPalette } from '../ui/CommandPalette';
|
import { CommandPalette } from '../ui/CommandPalette';
|
||||||
import { HelpDialog } from '../ui/HelpDialog';
|
import { HelpDialog } from '../ui/HelpDialog';
|
||||||
@@ -19,8 +21,16 @@ import { cn } from '@/lib/utils';
|
|||||||
import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow } from '@/components/views';
|
import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow } from '@/components/views';
|
||||||
|
|
||||||
export const MainLayout: React.FC = () => {
|
export const MainLayout: React.FC = () => {
|
||||||
|
const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140;
|
||||||
|
const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220;
|
||||||
|
const BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT = 640;
|
||||||
|
const BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT = 700;
|
||||||
const {
|
const {
|
||||||
isSidebarOpen,
|
isSidebarOpen,
|
||||||
|
isRightSidebarOpen,
|
||||||
|
isBottomTerminalOpen,
|
||||||
|
setRightSidebarOpen,
|
||||||
|
setBottomTerminalOpen,
|
||||||
activeMainTab,
|
activeMainTab,
|
||||||
setIsMobile,
|
setIsMobile,
|
||||||
isSessionSwitcherOpen,
|
isSessionSwitcherOpen,
|
||||||
@@ -32,6 +42,8 @@ export const MainLayout: React.FC = () => {
|
|||||||
} = useUIStore();
|
} = useUIStore();
|
||||||
|
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
|
const rightSidebarAutoClosedRef = React.useRef(false);
|
||||||
|
const bottomTerminalAutoClosedRef = React.useRef(false);
|
||||||
|
|
||||||
useEdgeSwipe({ enabled: true });
|
useEdgeSwipe({ enabled: true });
|
||||||
|
|
||||||
@@ -78,6 +90,95 @@ export const MainLayout: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutId: number | undefined;
|
||||||
|
|
||||||
|
const handleResponsivePanels = () => {
|
||||||
|
const state = useUIStore.getState();
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
|
||||||
|
const shouldCloseRightSidebar = width < RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH;
|
||||||
|
const canAutoOpenRightSidebar = width >= RIGHT_SIDEBAR_AUTO_OPEN_WIDTH;
|
||||||
|
|
||||||
|
if (shouldCloseRightSidebar) {
|
||||||
|
if (state.isRightSidebarOpen) {
|
||||||
|
setRightSidebarOpen(false);
|
||||||
|
rightSidebarAutoClosedRef.current = true;
|
||||||
|
}
|
||||||
|
} else if (canAutoOpenRightSidebar && rightSidebarAutoClosedRef.current) {
|
||||||
|
setRightSidebarOpen(true);
|
||||||
|
rightSidebarAutoClosedRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldCloseBottomTerminal =
|
||||||
|
height < BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT;
|
||||||
|
const canAutoOpenBottomTerminal =
|
||||||
|
height >= BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT;
|
||||||
|
|
||||||
|
if (shouldCloseBottomTerminal) {
|
||||||
|
if (state.isBottomTerminalOpen) {
|
||||||
|
setBottomTerminalOpen(false);
|
||||||
|
bottomTerminalAutoClosedRef.current = true;
|
||||||
|
}
|
||||||
|
} else if (canAutoOpenBottomTerminal && bottomTerminalAutoClosedRef.current) {
|
||||||
|
setBottomTerminalOpen(true);
|
||||||
|
bottomTerminalAutoClosedRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (timeoutId !== undefined) {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutId = window.setTimeout(() => {
|
||||||
|
handleResponsivePanels();
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleResponsivePanels();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
if (timeoutId !== undefined) {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [setBottomTerminalOpen, setRightSidebarOpen]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = useUIStore.subscribe((state, prevState) => {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
|
||||||
|
const rightCanAutoOpen = width >= RIGHT_SIDEBAR_AUTO_OPEN_WIDTH;
|
||||||
|
const bottomCanAutoOpen =
|
||||||
|
height >= BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT;
|
||||||
|
|
||||||
|
if (state.isRightSidebarOpen !== prevState.isRightSidebarOpen && rightCanAutoOpen) {
|
||||||
|
rightSidebarAutoClosedRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isBottomTerminalOpen !== prevState.isBottomTerminalOpen && bottomCanAutoOpen) {
|
||||||
|
bottomTerminalAutoClosedRef.current = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -378,6 +479,8 @@ export const MainLayout: React.FC = () => {
|
|||||||
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile}>
|
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile}>
|
||||||
<SessionSidebar />
|
<SessionSidebar />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
<div className="flex flex-1 min-w-0 flex-col overflow-hidden">
|
||||||
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
<main className="flex-1 overflow-hidden bg-background relative">
|
<main className="flex-1 overflow-hidden bg-background relative">
|
||||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||||
<ErrorBoundary><ChatView /></ErrorBoundary>
|
<ErrorBoundary><ChatView /></ErrorBoundary>
|
||||||
@@ -388,6 +491,14 @@ export const MainLayout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
<RightSidebar isOpen={isRightSidebarOpen} isMobile={isMobile}>
|
||||||
|
<ErrorBoundary><GitView mode="sidebar" /></ErrorBoundary>
|
||||||
|
</RightSidebar>
|
||||||
|
</div>
|
||||||
|
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
|
||||||
|
<ErrorBoundary><TerminalView /></ErrorBoundary>
|
||||||
|
</BottomTerminalDock>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
|
||||||
|
const RIGHT_SIDEBAR_MIN_WIDTH = 400;
|
||||||
|
const RIGHT_SIDEBAR_MAX_WIDTH = 860;
|
||||||
|
|
||||||
|
interface RightSidebarProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, isMobile, children }) => {
|
||||||
|
const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth);
|
||||||
|
const setRightSidebarWidth = useUIStore((state) => state.setRightSidebarWidth);
|
||||||
|
const [isResizing, setIsResizing] = React.useState(false);
|
||||||
|
const startXRef = React.useRef(0);
|
||||||
|
const startWidthRef = React.useRef(rightSidebarWidth || 420);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isMobile || !isResizing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
|
const delta = startXRef.current - event.clientX;
|
||||||
|
const nextWidth = Math.min(
|
||||||
|
RIGHT_SIDEBAR_MAX_WIDTH,
|
||||||
|
Math.max(RIGHT_SIDEBAR_MIN_WIDTH, startWidthRef.current + delta)
|
||||||
|
);
|
||||||
|
setRightSidebarWidth(nextWidth);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = () => {
|
||||||
|
setIsResizing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', handlePointerMove);
|
||||||
|
window.addEventListener('pointerup', handlePointerUp, { once: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointermove', handlePointerMove);
|
||||||
|
window.removeEventListener('pointerup', handlePointerUp);
|
||||||
|
};
|
||||||
|
}, [isMobile, isResizing, setRightSidebarWidth]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isMobile && isResizing) {
|
||||||
|
setIsResizing(false);
|
||||||
|
}
|
||||||
|
}, [isMobile, isResizing]);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appliedWidth = isOpen
|
||||||
|
? Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, rightSidebarWidth || 420))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const handlePointerDown = (event: React.PointerEvent) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsResizing(true);
|
||||||
|
startXRef.current = event.clientX;
|
||||||
|
startWidthRef.current = appliedWidth;
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'relative flex h-full overflow-hidden border-l border-border bg-sidebar',
|
||||||
|
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
|
||||||
|
!isOpen && 'border-l-0'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: `${appliedWidth}px`,
|
||||||
|
minWidth: `${appliedWidth}px`,
|
||||||
|
maxWidth: `${appliedWidth}px`,
|
||||||
|
overflowX: 'clip',
|
||||||
|
}}
|
||||||
|
aria-hidden={!isOpen || appliedWidth === 0}
|
||||||
|
>
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute left-0 top-0 z-20 h-full w-[4px] cursor-col-resize hover:bg-primary/50 transition-colors',
|
||||||
|
isResizing && 'bg-primary'
|
||||||
|
)}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize right panel"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative z-10 flex h-full min-h-0 w-full flex-col transition-opacity duration-300 ease-in-out',
|
||||||
|
!isOpen && 'pointer-events-none select-none opacity-0'
|
||||||
|
)}
|
||||||
|
aria-hidden={!isOpen}
|
||||||
|
>
|
||||||
|
{isOpen ? children : null}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RiDownloadLine, RiInformationLine, RiSettings3Line } from '@remixicon/react';
|
import { RiDownloadLine, RiInformationLine, RiQuestionLine, RiSettings3Line } from '@remixicon/react';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||||
@@ -20,7 +20,7 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children }) => {
|
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children }) => {
|
||||||
const { sidebarWidth, setSidebarWidth, setSettingsDialogOpen, setAboutDialogOpen } = useUIStore();
|
const { sidebarWidth, setSidebarWidth, setSettingsDialogOpen, setAboutDialogOpen, toggleHelpDialog } = useUIStore();
|
||||||
const [isResizing, setIsResizing] = React.useState(false);
|
const [isResizing, setIsResizing] = React.useState(false);
|
||||||
const startXRef = React.useRef(0);
|
const startXRef = React.useRef(0);
|
||||||
const startWidthRef = React.useRef(sidebarWidth || SIDEBAR_CONTENT_WIDTH);
|
const startWidthRef = React.useRef(sidebarWidth || SIDEBAR_CONTENT_WIDTH);
|
||||||
@@ -193,6 +193,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
|||||||
<RiSettings3Line className="h-4 w-4" />
|
<RiSettings3Line className="h-4 w-4" />
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
{(available || downloaded) ? (
|
{(available || downloaded) ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setUpdateDialogOpen(true)}
|
onClick={() => setUpdateDialogOpen(true)}
|
||||||
@@ -226,6 +227,24 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
|||||||
<TooltipContent side="top">About OpenChamber</TooltipContent>
|
<TooltipContent side="top">About OpenChamber</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={toggleHelpDialog}
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-md',
|
||||||
|
'text-sidebar-foreground/70',
|
||||||
|
'hover:text-sidebar-foreground hover:bg-interactive-hover',
|
||||||
|
'transition-all duration-200'
|
||||||
|
)}
|
||||||
|
aria-label="Keyboard shortcuts"
|
||||||
|
>
|
||||||
|
<RiQuestionLine className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">Keyboard shortcuts</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UpdateDialog
|
<UpdateDialog
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -55,6 +54,135 @@ interface McpDropdownProps {
|
|||||||
headerIconButtonClass: string;
|
headerIconButtonClass: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface McpDropdownContentProps {
|
||||||
|
active: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active, className }) => {
|
||||||
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
|
const directory = currentDirectory ?? null;
|
||||||
|
const status = useMcpStore((state) => state.getStatusForDirectory(directory));
|
||||||
|
const refresh = useMcpStore((state) => state.refresh);
|
||||||
|
const connect = useMcpStore((state) => state.connect);
|
||||||
|
const disconnect = useMcpStore((state) => state.disconnect);
|
||||||
|
const [isSpinning, setIsSpinning] = React.useState(false);
|
||||||
|
const [busyName, setBusyName] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void refresh({ directory, silent: true });
|
||||||
|
}, [refresh, directory]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
void refresh({ directory, silent: true });
|
||||||
|
}, [active, refresh, directory]);
|
||||||
|
|
||||||
|
const sortedNames = React.useMemo(() => {
|
||||||
|
return Object.keys(status).sort((a, b) => a.localeCompare(b));
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const handleRefresh = React.useCallback((e?: React.MouseEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (isSpinning) return;
|
||||||
|
setIsSpinning(true);
|
||||||
|
const minSpinPromise = new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
Promise.all([refresh({ directory }), minSpinPromise]).finally(() => {
|
||||||
|
setIsSpinning(false);
|
||||||
|
});
|
||||||
|
}, [isSpinning, refresh, directory]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full', className)}>
|
||||||
|
<div className="sticky top-0 z-20 bg-[var(--surface-elevated)] border-b border-[var(--interactive-border)]">
|
||||||
|
<div className="flex items-center justify-between gap-3 px-2 py-2.5">
|
||||||
|
<div className="min-w-0 flex items-center gap-2">
|
||||||
|
<div className="typography-ui-header font-semibold text-foreground">MCP Servers</div>
|
||||||
|
{directory && (
|
||||||
|
<div className="truncate typography-ui-label text-muted-foreground">
|
||||||
|
{directory.split('/').pop() || directory}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
|
disabled={isSpinning}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
aria-label="Refresh"
|
||||||
|
>
|
||||||
|
<RiRefreshLine className={cn('h-4 w-4', isSpinning && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-64 overflow-y-auto py-1">
|
||||||
|
{sortedNames.map((serverName) => {
|
||||||
|
const serverStatus = status[serverName];
|
||||||
|
const tone = statusTone(serverStatus);
|
||||||
|
const isConnected = serverStatus?.status === 'connected';
|
||||||
|
const isBusy = busyName === serverName;
|
||||||
|
const tooltip = statusTooltip(serverStatus);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={serverName}
|
||||||
|
className="flex items-center justify-between gap-2 px-2 py-1.5 rounded-lg hover:bg-interactive-hover/50"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Tooltip delayDuration={300}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'h-2 w-2 rounded-full flex-shrink-0',
|
||||||
|
tone === 'success' && 'bg-status-success',
|
||||||
|
tone === 'error' && 'bg-status-error',
|
||||||
|
tone === 'warning' && 'bg-status-warning',
|
||||||
|
tone === 'default' && 'bg-muted-foreground/40'
|
||||||
|
)}
|
||||||
|
aria-label={tooltip}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">
|
||||||
|
<p>{tooltip}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<span className="typography-ui-label truncate">{serverName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
checked={isConnected}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="data-[state=checked]:bg-status-info"
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
setBusyName(serverName);
|
||||||
|
try {
|
||||||
|
if (checked) {
|
||||||
|
await connect(serverName, directory);
|
||||||
|
} else {
|
||||||
|
await disconnect(serverName, directory);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusyName(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{sortedNames.length === 0 && (
|
||||||
|
<div className="px-2 py-3 typography-ui-label text-muted-foreground text-center">
|
||||||
|
Configure MCP servers in Opencode config.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const McpDropdown: React.FC<McpDropdownProps> = ({ headerIconButtonClass }) => {
|
export const McpDropdown: React.FC<McpDropdownProps> = ({ headerIconButtonClass }) => {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [tooltipOpen, setTooltipOpen] = React.useState(false);
|
const [tooltipOpen, setTooltipOpen] = React.useState(false);
|
||||||
@@ -277,35 +405,7 @@ export const McpDropdown: React.FC<McpDropdownProps> = ({ headerIconButtonClass
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<DropdownMenuContent align="end" className="w-72">
|
<DropdownMenuContent align="end" className="w-72">
|
||||||
<div className="flex items-center justify-between px-2 py-1">
|
<McpDropdownContent active={open} />
|
||||||
<span className="typography-ui-label font-semibold">MCP Servers</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover transition-colors"
|
|
||||||
disabled={isSpinning}
|
|
||||||
onClick={handleRefresh}
|
|
||||||
aria-label="Refresh"
|
|
||||||
>
|
|
||||||
<RiRefreshLine className={cn('h-4 w-4', isSpinning && 'animate-spin')} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
<div className="max-h-64 overflow-y-auto py-1">
|
|
||||||
{renderServerList()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{directory && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<div className="px-2 py-1">
|
|
||||||
<span className="typography-meta text-muted-foreground truncate block">
|
|
||||||
{directory.split('/').pop() || directory}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -41,10 +41,6 @@ import {
|
|||||||
RiFileCopyLine,
|
RiFileCopyLine,
|
||||||
RiFolderAddLine,
|
RiFolderAddLine,
|
||||||
RiGitBranchLine,
|
RiGitBranchLine,
|
||||||
RiGitClosePullRequestLine,
|
|
||||||
RiGitMergeLine,
|
|
||||||
RiGitPrDraftLine,
|
|
||||||
RiGitPullRequestLine,
|
|
||||||
RiLinkUnlinkM,
|
RiLinkUnlinkM,
|
||||||
|
|
||||||
RiGithubLine,
|
RiGithubLine,
|
||||||
@@ -71,10 +67,7 @@ import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
|||||||
import { useGitStore } from '@/stores/useGitStore';
|
import { useGitStore } from '@/stores/useGitStore';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
||||||
import type { GitHubPullRequestStatus } from '@/lib/api/types';
|
|
||||||
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
|
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
|
||||||
import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog';
|
|
||||||
|
|
||||||
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
|
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
|
||||||
|
|
||||||
@@ -87,9 +80,6 @@ const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
|
|||||||
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
|
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
|
||||||
const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject';
|
const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject';
|
||||||
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
|
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
|
||||||
const PR_REVALIDATE_TTL_MS = 90_000;
|
|
||||||
const PR_REVALIDATE_INTERVAL_MS = 90_000;
|
|
||||||
const PR_REVALIDATE_CONCURRENCY = 3;
|
|
||||||
|
|
||||||
const formatDateLabel = (value: string | number) => {
|
const formatDateLabel = (value: string | number) => {
|
||||||
const targetDate = new Date(value);
|
const targetDate = new Date(value);
|
||||||
@@ -150,58 +140,6 @@ const toFiniteNumber = (value: unknown): number | undefined => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPrVisualState = (status: GitHubPullRequestStatus | null): 'draft' | 'open' | 'blocked' | 'merged' | 'closed' | null => {
|
|
||||||
const pr = status?.pr;
|
|
||||||
if (!pr) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (pr.state === 'merged') {
|
|
||||||
return 'merged';
|
|
||||||
}
|
|
||||||
if (pr.state === 'closed') {
|
|
||||||
return 'closed';
|
|
||||||
}
|
|
||||||
if (pr.draft) {
|
|
||||||
return 'draft';
|
|
||||||
}
|
|
||||||
const checksFailed = status?.checks?.state === 'failure';
|
|
||||||
const notMergeable = status?.canMerge === false || pr.mergeable === false;
|
|
||||||
if (checksFailed || notMergeable) {
|
|
||||||
return 'blocked';
|
|
||||||
}
|
|
||||||
return 'open';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPrTooltipLabel = (status: GitHubPullRequestStatus | null): string => {
|
|
||||||
const pr = status?.pr;
|
|
||||||
if (!pr) {
|
|
||||||
return 'Open pull request';
|
|
||||||
}
|
|
||||||
const parts: string[] = [`PR #${pr.number}`];
|
|
||||||
if (pr.state === 'merged') {
|
|
||||||
parts.push('Merged');
|
|
||||||
} else if (pr.state === 'closed') {
|
|
||||||
parts.push('Closed');
|
|
||||||
} else if (pr.draft) {
|
|
||||||
parts.push('Draft');
|
|
||||||
} else {
|
|
||||||
parts.push('Open');
|
|
||||||
}
|
|
||||||
if (status?.checks?.state === 'failure') {
|
|
||||||
parts.push('Checks failing');
|
|
||||||
}
|
|
||||||
if (status?.canMerge === false || pr.mergeable === false) {
|
|
||||||
parts.push('Merge blocked');
|
|
||||||
}
|
|
||||||
return parts.join(' · ');
|
|
||||||
};
|
|
||||||
|
|
||||||
type TauriShell = {
|
|
||||||
shell?: {
|
|
||||||
open?: (url: string) => Promise<unknown>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const centerDragOverlayUnderPointer: Modifier = ({ transform, activeNodeRect, activatorEvent }) => {
|
const centerDragOverlayUnderPointer: Modifier = ({ transform, activeNodeRect, activatorEvent }) => {
|
||||||
if (!(activatorEvent instanceof MouseEvent) || !activeNodeRect) {
|
if (!(activatorEvent instanceof MouseEvent) || !activeNodeRect) {
|
||||||
return transform;
|
return transform;
|
||||||
@@ -256,7 +194,6 @@ interface SortableProjectItemProps {
|
|||||||
onNewSession: () => void;
|
onNewSession: () => void;
|
||||||
onNewWorktreeSession?: () => void;
|
onNewWorktreeSession?: () => void;
|
||||||
onNewSessionFromGitHubIssue?: () => void;
|
onNewSessionFromGitHubIssue?: () => void;
|
||||||
onNewSessionFromGitHubPR?: () => void;
|
|
||||||
onOpenMultiRunLauncher: () => void;
|
onOpenMultiRunLauncher: () => void;
|
||||||
onRenameStart: () => void;
|
onRenameStart: () => void;
|
||||||
onRenameSave: () => void;
|
onRenameSave: () => void;
|
||||||
@@ -289,7 +226,6 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
onNewSession,
|
onNewSession,
|
||||||
onNewWorktreeSession,
|
onNewWorktreeSession,
|
||||||
onNewSessionFromGitHubIssue,
|
onNewSessionFromGitHubIssue,
|
||||||
onNewSessionFromGitHubPR,
|
|
||||||
onOpenMultiRunLauncher,
|
onOpenMultiRunLauncher,
|
||||||
onRenameStart,
|
onRenameStart,
|
||||||
onRenameSave,
|
onRenameSave,
|
||||||
@@ -448,12 +384,6 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
New session from GitHub issue
|
New session from GitHub issue
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{showCreateButtons && isRepo && !hideDirectoryControls && onNewSessionFromGitHubPR && (
|
|
||||||
<DropdownMenuItem onClick={onNewSessionFromGitHubPR}>
|
|
||||||
<RiGitPullRequestLine className="mr-1.5 h-4 w-4" />
|
|
||||||
New session from GitHub PR
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{showCreateButtons && isRepo && !hideDirectoryControls && (
|
{showCreateButtons && isRepo && !hideDirectoryControls && (
|
||||||
<DropdownMenuItem onClick={onOpenMultiRunLauncher}>
|
<DropdownMenuItem onClick={onOpenMultiRunLauncher}>
|
||||||
<ArrowsMerge className="mr-1.5 h-4 w-4" />
|
<ArrowsMerge className="mr-1.5 h-4 w-4" />
|
||||||
@@ -604,8 +534,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
|
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
|
||||||
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
|
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
|
||||||
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
||||||
const [pullRequestPickerOpen, setPullRequestPickerOpen] = React.useState(false);
|
|
||||||
const [worktreePrByGroupKey, setWorktreePrByGroupKey] = React.useState<Map<string, GitHubPullRequestStatus>>(new Map());
|
|
||||||
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
|
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
|
||||||
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
|
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
|
||||||
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
|
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
|
||||||
@@ -661,7 +589,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const [isProjectRenameInline, setIsProjectRenameInline] = React.useState(false);
|
const [isProjectRenameInline, setIsProjectRenameInline] = React.useState(false);
|
||||||
const [projectRenameDraft, setProjectRenameDraft] = React.useState('');
|
const [projectRenameDraft, setProjectRenameDraft] = React.useState('');
|
||||||
const [projectRootBranches, setProjectRootBranches] = React.useState<Map<string, string>>(new Map());
|
const [projectRootBranches, setProjectRootBranches] = React.useState<Map<string, string>>(new Map());
|
||||||
const worktreePrLastCheckedAtRef = React.useRef<Map<string, number>>(new Map());
|
|
||||||
const projectHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
|
const projectHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||||
const ignoreIntersectionUntil = React.useRef<number>(0);
|
const ignoreIntersectionUntil = React.useRef<number>(0);
|
||||||
const persistCollapsedProjectsTimer = React.useRef<number | null>(null);
|
const persistCollapsedProjectsTimer = React.useRef<number | null>(null);
|
||||||
@@ -682,8 +609,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||||
const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher);
|
const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher);
|
||||||
const { github, git } = useRuntimeAPIs();
|
|
||||||
|
|
||||||
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
|
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
|
||||||
|
|
||||||
const gitDirectories = useGitStore((state) => state.directories);
|
const gitDirectories = useGitStore((state) => state.directories);
|
||||||
@@ -709,24 +634,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
|
|
||||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
|
|
||||||
const openExternal = React.useCallback(async (url: string) => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__;
|
|
||||||
if (tauri?.shell?.open) {
|
|
||||||
try {
|
|
||||||
await tauri.shell.open(url);
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
// fall through
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const flushCollapsedProjectsPersist = React.useCallback(() => {
|
const flushCollapsedProjectsPersist = React.useCallback(() => {
|
||||||
if (isVSCode) {
|
if (isVSCode) {
|
||||||
return;
|
return;
|
||||||
@@ -1493,219 +1400,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
);
|
);
|
||||||
const reserveHeaderActionsSpace = activeProjectRepoState !== false;
|
const reserveHeaderActionsSpace = activeProjectRepoState !== false;
|
||||||
|
|
||||||
const worktreePrInFlight = React.useRef<Set<string>>(new Set());
|
|
||||||
const visibleWorktreeGroups = React.useMemo(() => {
|
|
||||||
return visibleProjectSections.flatMap((section) =>
|
|
||||||
section.groups
|
|
||||||
.filter((group) => !group.isMain)
|
|
||||||
.map((group) => ({
|
|
||||||
key: `${section.project.id}:${group.id}`,
|
|
||||||
directory: group.directory,
|
|
||||||
label: group.label,
|
|
||||||
worktree: group.worktree,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}, [visibleProjectSections]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const validKeys = new Set<string>();
|
|
||||||
projectSections.forEach((section) => {
|
|
||||||
section.groups.forEach((group) => {
|
|
||||||
if (!group.isMain) {
|
|
||||||
validKeys.add(`${section.project.id}:${group.id}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
setWorktreePrByGroupKey((prev) => {
|
|
||||||
if (prev.size === 0) return prev;
|
|
||||||
const next = new Map(prev);
|
|
||||||
let mutated = false;
|
|
||||||
Array.from(next.keys()).forEach((key) => {
|
|
||||||
if (!validKeys.has(key)) {
|
|
||||||
next.delete(key);
|
|
||||||
mutated = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return mutated ? next : prev;
|
|
||||||
});
|
|
||||||
Array.from(worktreePrLastCheckedAtRef.current.keys()).forEach((key) => {
|
|
||||||
if (!validKeys.has(key)) {
|
|
||||||
worktreePrLastCheckedAtRef.current.delete(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [projectSections]);
|
|
||||||
|
|
||||||
const ensureWorktreePrLoaded = React.useCallback(async (
|
|
||||||
groupKey: string,
|
|
||||||
directory: string | null,
|
|
||||||
label: string,
|
|
||||||
worktree?: WorktreeMetadata | null,
|
|
||||||
options?: { force?: boolean },
|
|
||||||
) => {
|
|
||||||
if (!github?.prStatus || !directory || worktreePrInFlight.current.has(groupKey)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lastCheckedAt = worktreePrLastCheckedAtRef.current.get(groupKey) ?? 0;
|
|
||||||
const isFresh = Date.now() - lastCheckedAt < PR_REVALIDATE_TTL_MS;
|
|
||||||
if (!options?.force && isFresh) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedBranchFromDirectory = await git.getGitStatus(directory)
|
|
||||||
.then((status) => status?.current ?? '')
|
|
||||||
.catch(() => '');
|
|
||||||
const normalizeBranchCandidate = (value: string) => value
|
|
||||||
.replace(/^refs\/heads\//, '')
|
|
||||||
.replace(/^remotes\//, '')
|
|
||||||
.replace(/^origin\//, '')
|
|
||||||
.trim();
|
|
||||||
const branches = [resolvedBranchFromDirectory, worktree?.branch, worktree?.name, worktree?.label, label]
|
|
||||||
.map((value) => (value || '').trim())
|
|
||||||
.map(normalizeBranchCandidate)
|
|
||||||
.filter((value) => value.length > 0);
|
|
||||||
const uniqueBranches = Array.from(new Set(branches));
|
|
||||||
if (uniqueBranches.length === 0) {
|
|
||||||
worktreePrLastCheckedAtRef.current.set(groupKey, Date.now());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
worktreePrInFlight.current.add(groupKey);
|
|
||||||
try {
|
|
||||||
let matched: GitHubPullRequestStatus | null = null;
|
|
||||||
for (const branch of uniqueBranches) {
|
|
||||||
const status = await github.prStatus(directory, branch);
|
|
||||||
const hasPr = status?.connected !== false && Boolean(status?.pr);
|
|
||||||
if (hasPr) {
|
|
||||||
matched = status;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setWorktreePrByGroupKey((prev) => {
|
|
||||||
const current = prev.get(groupKey);
|
|
||||||
if (!matched) {
|
|
||||||
if (!current) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.delete(groupKey);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPr = current?.pr;
|
|
||||||
const nextPr = matched.pr;
|
|
||||||
const unchanged = Boolean(
|
|
||||||
currentPr
|
|
||||||
&& nextPr
|
|
||||||
&& currentPr.number === nextPr.number
|
|
||||||
&& currentPr.state === nextPr.state
|
|
||||||
&& currentPr.draft === nextPr.draft
|
|
||||||
&& currentPr.mergeable === nextPr.mergeable
|
|
||||||
&& current?.canMerge === matched.canMerge
|
|
||||||
&& current?.checks?.state === matched.checks?.state
|
|
||||||
&& current?.checks?.failure === matched.checks?.failure
|
|
||||||
&& current?.checks?.pending === matched.checks?.pending
|
|
||||||
&& current?.checks?.success === matched.checks?.success
|
|
||||||
);
|
|
||||||
|
|
||||||
if (unchanged) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(groupKey, matched);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setWorktreePrByGroupKey((prev) => {
|
|
||||||
if (!prev.has(groupKey)) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.delete(groupKey);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
worktreePrLastCheckedAtRef.current.set(groupKey, Date.now());
|
|
||||||
worktreePrInFlight.current.delete(groupKey);
|
|
||||||
}
|
|
||||||
}, [github, git]);
|
|
||||||
|
|
||||||
const revalidateVisibleWorktreePrs = React.useCallback(async (options?: { force?: boolean; onlyExistingPr?: boolean }) => {
|
|
||||||
const targetGroups = visibleWorktreeGroups.filter((group) => {
|
|
||||||
if (!group.directory) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (options?.onlyExistingPr && !worktreePrByGroupKey.has(group.key)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if (targetGroups.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cursor = 0;
|
|
||||||
const workerCount = Math.min(PR_REVALIDATE_CONCURRENCY, targetGroups.length);
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: workerCount }).map(async () => {
|
|
||||||
while (cursor < targetGroups.length) {
|
|
||||||
const index = cursor;
|
|
||||||
cursor += 1;
|
|
||||||
const group = targetGroups[index];
|
|
||||||
await ensureWorktreePrLoaded(group.key, group.directory, group.label, group.worktree, { force: options?.force });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, [visibleWorktreeGroups, worktreePrByGroupKey, ensureWorktreePrLoaded]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (visibleWorktreeGroups.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
void revalidateVisibleWorktreePrs();
|
|
||||||
}, 120);
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [visibleWorktreeGroups, revalidateVisibleWorktreePrs]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!activeProjectId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void revalidateVisibleWorktreePrs();
|
|
||||||
}, [activeProjectId, revalidateVisibleWorktreePrs]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const onFocus = () => {
|
|
||||||
void revalidateVisibleWorktreePrs();
|
|
||||||
};
|
|
||||||
const onVisibility = () => {
|
|
||||||
if (document.visibilityState === 'visible') {
|
|
||||||
void revalidateVisibleWorktreePrs();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('focus', onFocus);
|
|
||||||
document.addEventListener('visibilitychange', onVisibility);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('focus', onFocus);
|
|
||||||
document.removeEventListener('visibilitychange', onVisibility);
|
|
||||||
};
|
|
||||||
}, [revalidateVisibleWorktreePrs]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const interval = window.setInterval(() => {
|
|
||||||
if (document.visibilityState !== 'visible') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void revalidateVisibleWorktreePrs({ onlyExistingPr: true });
|
|
||||||
}, PR_REVALIDATE_INTERVAL_MS);
|
|
||||||
return () => {
|
|
||||||
window.clearInterval(interval);
|
|
||||||
};
|
|
||||||
}, [revalidateVisibleWorktreePrs]);
|
|
||||||
|
|
||||||
const projectSessionMeta = React.useMemo(() => {
|
const projectSessionMeta = React.useMemo(() => {
|
||||||
const metaByProject = new Map<string, Map<string, { directory: string | null }>>();
|
const metaByProject = new Map<string, Map<string, { directory: string | null }>>();
|
||||||
const firstSessionByProject = new Map<string, { id: string; directory: string | null }>();
|
const firstSessionByProject = new Map<string, { id: string; directory: string | null }>();
|
||||||
@@ -2273,17 +1967,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const allGroupSessions = collectGroupSessions(group.sessions);
|
const allGroupSessions = collectGroupSessions(group.sessions);
|
||||||
const normalizedGroupDirectory = normalizePath(group.directory ?? null);
|
const normalizedGroupDirectory = normalizePath(group.directory ?? null);
|
||||||
const isGitProject = Boolean(projectId && projectRepoStatus.get(projectId));
|
const isGitProject = Boolean(projectId && projectRepoStatus.get(projectId));
|
||||||
const groupPrStatus = projectId ? worktreePrByGroupKey.get(groupKey) ?? null : null;
|
|
||||||
const groupPr = groupPrStatus?.pr ?? null;
|
|
||||||
const prVisualState = getPrVisualState(groupPrStatus);
|
|
||||||
const prColorVar = prVisualState ? `var(--pr-${prVisualState})` : 'var(--status-info)';
|
|
||||||
const PrStateIcon = prVisualState === 'draft'
|
|
||||||
? RiGitPrDraftLine
|
|
||||||
: prVisualState === 'merged'
|
|
||||||
? RiGitMergeLine
|
|
||||||
: prVisualState === 'closed'
|
|
||||||
? RiGitClosePullRequestLine
|
|
||||||
: RiGitPullRequestLine;
|
|
||||||
const isActiveGroup = Boolean(
|
const isActiveGroup = Boolean(
|
||||||
normalizedGroupDirectory
|
normalizedGroupDirectory
|
||||||
&& currentSessionDirectory
|
&& currentSessionDirectory
|
||||||
@@ -2294,15 +1977,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
<div className="oc-group">
|
<div className="oc-group">
|
||||||
<div
|
<div
|
||||||
className="group/gh flex items-start justify-between gap-2 py-1 min-w-0 rounded-sm hover:bg-interactive-hover/50 cursor-pointer"
|
className="group/gh flex items-start justify-between gap-2 py-1 min-w-0 rounded-sm hover:bg-interactive-hover/50 cursor-pointer"
|
||||||
onMouseEnter={() => {
|
|
||||||
if (!group.isMain) {
|
|
||||||
void ensureWorktreePrLoaded(groupKey, group.directory, group.label, group.worktree);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!group.isMain) {
|
|
||||||
void ensureWorktreePrLoaded(groupKey, group.directory, group.label, group.worktree);
|
|
||||||
}
|
|
||||||
setCollapsedGroups((prev) => {
|
setCollapsedGroups((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(groupKey)) {
|
if (next.has(groupKey)) {
|
||||||
@@ -2318,9 +1993,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!group.isMain) {
|
|
||||||
void ensureWorktreePrLoaded(groupKey, group.directory, group.label, group.worktree);
|
|
||||||
}
|
|
||||||
setCollapsedGroups((prev) => {
|
setCollapsedGroups((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(groupKey)) {
|
if (next.has(groupKey)) {
|
||||||
@@ -2341,31 +2013,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground mt-1" />
|
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground mt-1" />
|
||||||
)}
|
)}
|
||||||
{!group.isMain || isGitProject ? (
|
{!group.isMain || isGitProject ? (
|
||||||
!group.isMain && groupPr?.url ? (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (groupPr?.url) {
|
|
||||||
void openExternal(groupPr.url);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-interactive-hover/50 mt-1"
|
|
||||||
style={{ color: prColorVar }}
|
|
||||||
aria-label={getPrTooltipLabel(groupPrStatus)}
|
|
||||||
>
|
|
||||||
<PrStateIcon className="h-3.5 w-3.5 flex-shrink-0" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" sideOffset={4}>
|
|
||||||
<p>{getPrTooltipLabel(groupPrStatus)}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground mt-1" />
|
<RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground mt-1" />
|
||||||
)
|
|
||||||
) : null}
|
) : null}
|
||||||
<div className="min-w-0 flex flex-col">
|
<div className="min-w-0 flex flex-col">
|
||||||
<p className={cn('text-[15px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
|
<p className={cn('text-[15px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
|
||||||
@@ -2472,9 +2120,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
hideDirectoryControls,
|
hideDirectoryControls,
|
||||||
currentSessionDirectory,
|
currentSessionDirectory,
|
||||||
projectRepoStatus,
|
projectRepoStatus,
|
||||||
worktreePrByGroupKey,
|
|
||||||
openExternal,
|
|
||||||
ensureWorktreePrLoaded,
|
|
||||||
renderSessionNode,
|
renderSessionNode,
|
||||||
toggleGroupSessionLimit,
|
toggleGroupSessionLimit,
|
||||||
activeProjectId,
|
activeProjectId,
|
||||||
@@ -2663,19 +2308,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" sideOffset={4}><p>New from issue</p></TooltipContent>
|
<TooltipContent side="bottom" sideOffset={4}><p>New from issue</p></TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPullRequestPickerOpen(true)}
|
|
||||||
className={headerActionButtonClass}
|
|
||||||
aria-label="New from PR"
|
|
||||||
>
|
|
||||||
<RiGitPullRequestLine className="h-4.5 w-4.5" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" sideOffset={4}><p>New from PR</p></TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -2787,12 +2419,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
}
|
}
|
||||||
setIssuePickerOpen(true);
|
setIssuePickerOpen(true);
|
||||||
}}
|
}}
|
||||||
onNewSessionFromGitHubPR={() => {
|
|
||||||
if (projectKey !== activeProjectId) {
|
|
||||||
setActiveProject(projectKey);
|
|
||||||
}
|
|
||||||
setPullRequestPickerOpen(true);
|
|
||||||
}}
|
|
||||||
onOpenMultiRunLauncher={() => {
|
onOpenMultiRunLauncher={() => {
|
||||||
if (projectKey !== activeProjectId) {
|
if (projectKey !== activeProjectId) {
|
||||||
setActiveProject(projectKey);
|
setActiveProject(projectKey);
|
||||||
@@ -2899,17 +2525,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GitHubPullRequestPickerDialog
|
|
||||||
open={pullRequestPickerOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setPullRequestPickerOpen(open);
|
|
||||||
if (!open && mobileVariant) {
|
|
||||||
setActiveMainTab('chat');
|
|
||||||
setSessionSwitcherOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -64,11 +64,12 @@ interface TerminalViewportProps {
|
|||||||
fontSize: number;
|
fontSize: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
enableTouchScroll?: boolean;
|
enableTouchScroll?: boolean;
|
||||||
|
autoFocus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportProps>(
|
const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportProps>(
|
||||||
(
|
(
|
||||||
{ sessionKey, chunks, onInput, onResize, theme, fontFamily, fontSize, className, enableTouchScroll },
|
{ sessionKey, chunks, onInput, onResize, theme, fontFamily, fontSize, className, enableTouchScroll, autoFocus = true },
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -94,6 +95,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
const refocusTimeoutRef = React.useRef<number | null>(null);
|
const refocusTimeoutRef = React.useRef<number | null>(null);
|
||||||
const keydownProbeTimeoutRef = React.useRef<number | null>(null);
|
const keydownProbeTimeoutRef = React.useRef<number | null>(null);
|
||||||
const lastObservedValueRef = React.useRef('');
|
const lastObservedValueRef = React.useRef('');
|
||||||
|
const cursorBlinkStateRef = React.useRef<boolean | null>(null);
|
||||||
const [, forceRender] = React.useReducer((x) => x + 1, 0);
|
const [, forceRender] = React.useReducer((x) => x + 1, 0);
|
||||||
const [terminalReadyVersion, bumpTerminalReady] = React.useReducer((x) => x + 1, 0);
|
const [terminalReadyVersion, bumpTerminalReady] = React.useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
@@ -157,6 +159,36 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
});
|
});
|
||||||
}, [useHiddenInputOverlay]);
|
}, [useHiddenInputOverlay]);
|
||||||
|
|
||||||
|
const setTerminalCursorBlink = React.useCallback((enabled: boolean) => {
|
||||||
|
if (cursorBlinkStateRef.current === enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminal = terminalRef.current as unknown as {
|
||||||
|
setOption?: (key: string, value: unknown) => void;
|
||||||
|
options?: { cursorBlink?: boolean };
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
if (!terminal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof terminal.setOption === 'function') {
|
||||||
|
terminal.setOption('cursorBlink', enabled);
|
||||||
|
cursorBlinkStateRef.current = enabled;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (terminal.options) {
|
||||||
|
terminal.options.cursorBlink = enabled;
|
||||||
|
cursorBlinkStateRef.current = enabled;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const useTextInput = useHiddenInputOverlay && isAndroid;
|
const useTextInput = useHiddenInputOverlay && isAndroid;
|
||||||
|
|
||||||
const focusHiddenInput = React.useCallback((clientX?: number, clientY?: number) => {
|
const focusHiddenInput = React.useCallback((clientX?: number, clientY?: number) => {
|
||||||
@@ -198,6 +230,16 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
}
|
}
|
||||||
}, [useTextInput]);
|
}, [useTextInput]);
|
||||||
|
|
||||||
|
const focusTerminalInput = React.useCallback(() => {
|
||||||
|
if (useHiddenInputOverlay) {
|
||||||
|
focusHiddenInput();
|
||||||
|
setTerminalCursorBlink(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
terminalRef.current?.focus();
|
||||||
|
setTerminalCursorBlink(true);
|
||||||
|
}, [focusHiddenInput, setTerminalCursorBlink, useHiddenInputOverlay]);
|
||||||
|
|
||||||
const readEditableValue = React.useCallback((target: HTMLElement) => {
|
const readEditableValue = React.useCallback((target: HTMLElement) => {
|
||||||
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) {
|
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) {
|
||||||
return target.value;
|
return target.value;
|
||||||
@@ -839,6 +881,29 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
|
|
||||||
container.tabIndex = useHiddenInputOverlay ? -1 : 0;
|
container.tabIndex = useHiddenInputOverlay ? -1 : 0;
|
||||||
|
|
||||||
|
const handleTerminalTextareaFocus = () => {
|
||||||
|
setTerminalCursorBlink(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTerminalTextareaBlur = () => {
|
||||||
|
setTerminalCursorBlink(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDocumentFocusIn = (event: FocusEvent) => {
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (target && container.contains(target)) {
|
||||||
|
setTerminalCursorBlink(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTerminalCursorBlink(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWindowBlur = () => {
|
||||||
|
setTerminalCursorBlink(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let localTerminalTextarea: HTMLTextAreaElement | null = null;
|
||||||
|
|
||||||
const initialize = async () => {
|
const initialize = async () => {
|
||||||
try {
|
try {
|
||||||
const ghostty = await getGhostty();
|
const ghostty = await getGhostty();
|
||||||
@@ -859,6 +924,16 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
terminal.loadAddon(fitAddon);
|
terminal.loadAddon(fitAddon);
|
||||||
terminal.open(container);
|
terminal.open(container);
|
||||||
bumpTerminalReady();
|
bumpTerminalReady();
|
||||||
|
cursorBlinkStateRef.current = false;
|
||||||
|
|
||||||
|
localTerminalTextarea =
|
||||||
|
(terminal as unknown as { textarea?: HTMLTextAreaElement | null }).textarea
|
||||||
|
?? container.querySelector('textarea');
|
||||||
|
|
||||||
|
if (localTerminalTextarea) {
|
||||||
|
localTerminalTextarea.addEventListener('focus', handleTerminalTextareaFocus);
|
||||||
|
localTerminalTextarea.addEventListener('blur', handleTerminalTextareaBlur);
|
||||||
|
}
|
||||||
|
|
||||||
disableTerminalTextareas();
|
disableTerminalTextareas();
|
||||||
|
|
||||||
@@ -880,10 +955,6 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
|
|
||||||
fitTerminal();
|
fitTerminal();
|
||||||
setupTouchScroll();
|
setupTouchScroll();
|
||||||
if (!useHiddenInputOverlay) {
|
|
||||||
terminal.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
localDisposables = [
|
localDisposables = [
|
||||||
terminal.onData((data: string) => {
|
terminal.onData((data: string) => {
|
||||||
inputHandlerRef.current(data);
|
inputHandlerRef.current(data);
|
||||||
@@ -907,12 +978,22 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
|
|
||||||
void initialize();
|
void initialize();
|
||||||
|
|
||||||
|
document.addEventListener('focusin', handleDocumentFocusIn, true);
|
||||||
|
window.addEventListener('blur', handleWindowBlur);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
touchScrollCleanupRef.current?.();
|
touchScrollCleanupRef.current?.();
|
||||||
touchScrollCleanupRef.current = null;
|
touchScrollCleanupRef.current = null;
|
||||||
|
|
||||||
|
document.removeEventListener('focusin', handleDocumentFocusIn, true);
|
||||||
|
window.removeEventListener('blur', handleWindowBlur);
|
||||||
|
|
||||||
localDisposables.forEach((disposable) => disposable.dispose());
|
localDisposables.forEach((disposable) => disposable.dispose());
|
||||||
|
if (localTerminalTextarea) {
|
||||||
|
localTerminalTextarea.removeEventListener('focus', handleTerminalTextareaFocus);
|
||||||
|
localTerminalTextarea.removeEventListener('blur', handleTerminalTextareaBlur);
|
||||||
|
}
|
||||||
localResizeObserver?.disconnect();
|
localResizeObserver?.disconnect();
|
||||||
localTextareaObserver?.disconnect();
|
localTextareaObserver?.disconnect();
|
||||||
|
|
||||||
@@ -921,9 +1002,10 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
fitAddonRef.current = null;
|
fitAddonRef.current = null;
|
||||||
viewportRef.current = null;
|
viewportRef.current = null;
|
||||||
lastReportedSizeRef.current = null;
|
lastReportedSizeRef.current = null;
|
||||||
|
cursorBlinkStateRef.current = null;
|
||||||
resetWriteState();
|
resetWriteState();
|
||||||
};
|
};
|
||||||
}, [disableTerminalTextareas, useHiddenInputOverlay, fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState]);
|
}, [disableTerminalTextareas, fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState, setTerminalCursorBlink, useHiddenInputOverlay]);
|
||||||
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -935,10 +1017,20 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
resetWriteState();
|
resetWriteState();
|
||||||
lastReportedSizeRef.current = null;
|
lastReportedSizeRef.current = null;
|
||||||
fitTerminal();
|
fitTerminal();
|
||||||
if (!useHiddenInputOverlay) {
|
}, [sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]);
|
||||||
terminal.focus();
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!autoFocus) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [useHiddenInputOverlay, sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]);
|
|
||||||
|
const terminal = terminalRef.current;
|
||||||
|
if (!terminal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
focusTerminalInput();
|
||||||
|
}, [autoFocus, focusTerminalInput, sessionKey, terminalReadyVersion]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setupTouchScroll();
|
setupTouchScroll();
|
||||||
@@ -984,11 +1076,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
ref,
|
ref,
|
||||||
(): TerminalController => ({
|
(): TerminalController => ({
|
||||||
focus: () => {
|
focus: () => {
|
||||||
if (useHiddenInputOverlay) {
|
focusTerminalInput();
|
||||||
focusHiddenInput();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
terminalRef.current?.focus();
|
|
||||||
},
|
},
|
||||||
clear: () => {
|
clear: () => {
|
||||||
const terminal = terminalRef.current;
|
const terminal = terminalRef.current;
|
||||||
@@ -1003,7 +1091,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
fitTerminal();
|
fitTerminal();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[useHiddenInputOverlay, focusHiddenInput, fitTerminal, resetWriteState]
|
[focusTerminalInput, fitTerminal, resetWriteState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHiddenInputBlur = React.useCallback(
|
const handleHiddenInputBlur = React.useCallback(
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ interface AnimatedTabsProps<T extends string> {
|
|||||||
isInteractive?: boolean;
|
isInteractive?: boolean;
|
||||||
animate?: boolean;
|
animate?: boolean;
|
||||||
collapseLabelsOnSmall?: boolean;
|
collapseLabelsOnSmall?: boolean;
|
||||||
|
collapseLabelsOnNarrow?: boolean;
|
||||||
|
size?: 'default' | 'sm';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnimatedTabs<T extends string>({
|
export function AnimatedTabs<T extends string>({
|
||||||
@@ -25,9 +27,12 @@ export function AnimatedTabs<T extends string>({
|
|||||||
isInteractive = true,
|
isInteractive = true,
|
||||||
animate = true,
|
animate = true,
|
||||||
collapseLabelsOnSmall = false,
|
collapseLabelsOnSmall = false,
|
||||||
|
collapseLabelsOnNarrow = false,
|
||||||
|
size = 'default',
|
||||||
}: AnimatedTabsProps<T>) {
|
}: AnimatedTabsProps<T>) {
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const activeTabRef = React.useRef<HTMLButtonElement>(null);
|
const activeTabRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
const [isReadyToAnimate, setIsReadyToAnimate] = React.useState(false);
|
||||||
|
|
||||||
const updateClipPath = React.useCallback(() => {
|
const updateClipPath = React.useCallback(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -47,7 +52,10 @@ export function AnimatedTabs<T extends string>({
|
|||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
updateClipPath();
|
updateClipPath();
|
||||||
}, [updateClipPath, value, tabs.length]);
|
if (!isReadyToAnimate) {
|
||||||
|
setIsReadyToAnimate(true);
|
||||||
|
}
|
||||||
|
}, [isReadyToAnimate, updateClipPath, value, tabs.length]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -60,28 +68,34 @@ export function AnimatedTabs<T extends string>({
|
|||||||
}, [updateClipPath]);
|
}, [updateClipPath]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative isolate w-full', className)}>
|
<div className={cn('relative isolate w-full', collapseLabelsOnNarrow && '@container/animated-tabs', className)}>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-none absolute inset-0 z-10 overflow-hidden rounded-lg [clip-path:inset(0_75%_0_0_round_8px)]',
|
'pointer-events-none absolute inset-0 z-10 overflow-hidden rounded-lg [clip-path:inset(0_75%_0_0_round_8px)]',
|
||||||
animate ? '[transition:clip-path_200ms_ease]' : null
|
animate && isReadyToAnimate ? '[transition:clip-path_200ms_ease]' : null
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 bg-interactive-selection text-interactive-selection-foreground',
|
||||||
|
size === 'sm' ? 'h-7 rounded-md px-1' : 'h-9 rounded-lg px-1.5'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex h-9 items-center gap-1 rounded-lg bg-interactive-selection px-1.5 text-interactive-selection-foreground">
|
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-7 flex-1 items-center justify-center rounded-lg px-2.5 text-sm font-semibold',
|
'flex flex-1 items-center justify-center font-semibold',
|
||||||
|
size === 'sm' ? 'h-5 rounded-md px-2 text-xs' : 'h-7 rounded-lg px-2.5 text-sm',
|
||||||
collapseLabelsOnSmall ? 'gap-0 sm:gap-1.25' : 'gap-1.25'
|
collapseLabelsOnSmall ? 'gap-0 sm:gap-1.25' : 'gap-1.25'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{Icon ? <Icon className="h-4 w-4" /> : null}
|
{Icon ? <Icon className={cn(size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4')} /> : null}
|
||||||
<span className={cn('truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
|
<span className={cn('animated-tabs__label truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,7 +105,12 @@ export function AnimatedTabs<T extends string>({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-20 flex h-9 items-center gap-1 rounded-lg bg-muted/20 px-1.5">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative z-20 flex items-center gap-1 bg-muted/20',
|
||||||
|
size === 'sm' ? 'h-7 rounded-md px-1' : 'h-9 rounded-lg px-1.5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = value === tab.value;
|
const isActive = value === tab.value;
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
@@ -106,7 +125,8 @@ export function AnimatedTabs<T extends string>({
|
|||||||
onValueChange(tab.value);
|
onValueChange(tab.value);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-7 flex-1 items-center justify-center rounded-lg px-2.5 text-sm font-semibold transition-colors duration-150',
|
'animated-tabs__button flex flex-1 items-center justify-center font-semibold transition-colors duration-150',
|
||||||
|
size === 'sm' ? 'h-5 rounded-md px-2 text-xs' : 'h-7 rounded-lg px-2.5 text-sm',
|
||||||
collapseLabelsOnSmall ? 'gap-0 sm:gap-1.25' : 'gap-1.25',
|
collapseLabelsOnSmall ? 'gap-0 sm:gap-1.25' : 'gap-1.25',
|
||||||
isActive ? 'text-accent-foreground' : 'text-muted-foreground',
|
isActive ? 'text-accent-foreground' : 'text-muted-foreground',
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-background'
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-background'
|
||||||
@@ -118,10 +138,13 @@ export function AnimatedTabs<T extends string>({
|
|||||||
>
|
>
|
||||||
{Icon ? (
|
{Icon ? (
|
||||||
<Icon
|
<Icon
|
||||||
className={cn('h-4 w-4', isActive ? 'text-accent-foreground' : 'text-muted-foreground')}
|
className={cn(
|
||||||
|
size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4',
|
||||||
|
isActive ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<span className={cn('truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
|
<span className={cn('animated-tabs__label truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
useIsGitRepo,
|
useIsGitRepo,
|
||||||
} from '@/stores/useGitStore';
|
} from '@/stores/useGitStore';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||||
import {
|
import {
|
||||||
RiGitBranchLine,
|
RiGitBranchLine,
|
||||||
RiGitMergeLine,
|
RiGitMergeLine,
|
||||||
@@ -47,9 +48,9 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import { IntegrateCommitsSection } from './git/IntegrateCommitsSection';
|
import { IntegrateCommitsSection } from './git/IntegrateCommitsSection';
|
||||||
|
|
||||||
import { GitHeader } from './git/GitHeader';
|
import { GitHeader } from './git/GitHeader';
|
||||||
import { GitEmptyState } from './git/GitEmptyState';
|
|
||||||
import { ChangesSection } from './git/ChangesSection';
|
import { ChangesSection } from './git/ChangesSection';
|
||||||
import { CommitSection } from './git/CommitSection';
|
import { CommitSection } from './git/CommitSection';
|
||||||
|
import { GitEmptyState } from './git/GitEmptyState';
|
||||||
import { HistorySection } from './git/HistorySection';
|
import { HistorySection } from './git/HistorySection';
|
||||||
import { PullRequestSection } from './git/PullRequestSection';
|
import { PullRequestSection } from './git/PullRequestSection';
|
||||||
import { ConflictDialog } from './git/ConflictDialog';
|
import { ConflictDialog } from './git/ConflictDialog';
|
||||||
@@ -59,12 +60,18 @@ import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIn
|
|||||||
import type { GitRemote } from '@/lib/gitApi';
|
import type { GitRemote } from '@/lib/gitApi';
|
||||||
import { BranchPickerDialog } from '@/components/session/BranchPickerDialog';
|
import { BranchPickerDialog } from '@/components/session/BranchPickerDialog';
|
||||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
||||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||||
type BranchOperation = 'merge' | 'rebase' | null;
|
type BranchOperation = 'merge' | 'rebase' | null;
|
||||||
type ActionTab = 'commit' | 'branch' | 'pr' | 'worktree';
|
type ActionTab = 'commit' | 'branch' | 'pr' | 'worktree';
|
||||||
|
|
||||||
|
const GIT_ACTION_TAB_STORAGE_KEY = 'oc.git.actionTab';
|
||||||
|
|
||||||
|
const isActionTab = (value: unknown): value is ActionTab =>
|
||||||
|
value === 'commit' || value === 'branch' || value === 'pr' || value === 'worktree';
|
||||||
|
|
||||||
|
|
||||||
type GitViewSnapshot = {
|
type GitViewSnapshot = {
|
||||||
directory?: string;
|
directory?: string;
|
||||||
@@ -206,7 +213,11 @@ const gitViewSnapshots = new Map<string, GitViewSnapshot>();
|
|||||||
const normalizePath = (value?: string | null): string =>
|
const normalizePath = (value?: string | null): string =>
|
||||||
(value || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
(value || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
||||||
|
|
||||||
export const GitView: React.FC = () => {
|
interface GitViewProps {
|
||||||
|
mode?: 'full' | 'sidebar';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||||
const { git } = useRuntimeAPIs();
|
const { git } = useRuntimeAPIs();
|
||||||
const currentDirectory = useEffectiveDirectory();
|
const currentDirectory = useEffectiveDirectory();
|
||||||
const { currentSessionId, worktreeMetadata: worktreeMap } = useSessionStore();
|
const { currentSessionId, worktreeMetadata: worktreeMap } = useSessionStore();
|
||||||
@@ -294,11 +305,13 @@ export const GitView: React.FC = () => {
|
|||||||
initialSnapshot?.commitMessage ?? ''
|
initialSnapshot?.commitMessage ?? ''
|
||||||
);
|
);
|
||||||
const [isGitmojiPickerOpen, setIsGitmojiPickerOpen] = React.useState(false);
|
const [isGitmojiPickerOpen, setIsGitmojiPickerOpen] = React.useState(false);
|
||||||
|
const actionPanelScrollRef = React.useRef<HTMLElement | null>(null);
|
||||||
const [syncAction, setSyncAction] = React.useState<SyncAction>(null);
|
const [syncAction, setSyncAction] = React.useState<SyncAction>(null);
|
||||||
const [commitAction, setCommitAction] = React.useState<CommitAction>(null);
|
const [commitAction, setCommitAction] = React.useState<CommitAction>(null);
|
||||||
const [logMaxCountLocal, setLogMaxCountLocal] = React.useState<number>(25);
|
const [logMaxCountLocal, setLogMaxCountLocal] = React.useState<number>(25);
|
||||||
const [isSettingIdentity, setIsSettingIdentity] = React.useState(false);
|
const [isSettingIdentity, setIsSettingIdentity] = React.useState(false);
|
||||||
const { triggerFireworks } = useFireworksCelebration();
|
const { triggerFireworks } = useFireworksCelebration();
|
||||||
|
const isSidebarMode = mode === 'sidebar';
|
||||||
|
|
||||||
const autoAppliedDefaultRef = React.useRef<Map<string, string>>(new Map());
|
const autoAppliedDefaultRef = React.useRef<Map<string, string>>(new Map());
|
||||||
const identityApplyCountRef = React.useRef(0);
|
const identityApplyCountRef = React.useRef(0);
|
||||||
@@ -326,6 +339,17 @@ export const GitView: React.FC = () => {
|
|||||||
initialSnapshot?.generatedHighlights ?? []
|
initialSnapshot?.generatedHighlights ?? []
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const scrollActionPanelToBottom = React.useCallback(() => {
|
||||||
|
const scrollTarget = actionPanelScrollRef.current;
|
||||||
|
if (!scrollTarget) return;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollTarget.scrollTo({ top: scrollTarget.scrollHeight, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const repoRootForIntegrate = worktreeMetadata?.projectDirectory || null;
|
const repoRootForIntegrate = worktreeMetadata?.projectDirectory || null;
|
||||||
const sourceBranchForIntegrate = status?.current || null;
|
const sourceBranchForIntegrate = status?.current || null;
|
||||||
const shouldShowIntegrateCommits = React.useMemo(() => {
|
const shouldShowIntegrateCommits = React.useMemo(() => {
|
||||||
@@ -372,7 +396,13 @@ export const GitView: React.FC = () => {
|
|||||||
const [gitmojiEmojis, setGitmojiEmojis] = React.useState<GitmojiEntry[]>([]);
|
const [gitmojiEmojis, setGitmojiEmojis] = React.useState<GitmojiEntry[]>([]);
|
||||||
const [gitmojiSearch, setGitmojiSearch] = React.useState('');
|
const [gitmojiSearch, setGitmojiSearch] = React.useState('');
|
||||||
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = React.useState(false);
|
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = React.useState(false);
|
||||||
const [actionTab, setActionTab] = React.useState<ActionTab>('commit');
|
const [actionTab, setActionTab] = React.useState<ActionTab>(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'commit';
|
||||||
|
}
|
||||||
|
const stored = window.localStorage.getItem(GIT_ACTION_TAB_STORAGE_KEY);
|
||||||
|
return isActionTab(stored) ? stored : 'commit';
|
||||||
|
});
|
||||||
const [remotes, setRemotes] = React.useState<GitRemote[]>([]);
|
const [remotes, setRemotes] = React.useState<GitRemote[]>([]);
|
||||||
const [branchOperation, setBranchOperation] = React.useState<BranchOperation>(null);
|
const [branchOperation, setBranchOperation] = React.useState<BranchOperation>(null);
|
||||||
const [operationLogs, setOperationLogs] = React.useState<OperationLogEntry[]>([]);
|
const [operationLogs, setOperationLogs] = React.useState<OperationLogEntry[]>([]);
|
||||||
@@ -403,6 +433,13 @@ export const GitView: React.FC = () => {
|
|||||||
window.localStorage.removeItem(conflictStorageKey);
|
window.localStorage.removeItem(conflictStorageKey);
|
||||||
}, [conflictStorageKey]);
|
}, [conflictStorageKey]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(GIT_ACTION_TAB_STORAGE_KEY, actionTab);
|
||||||
|
}, [actionTab]);
|
||||||
|
|
||||||
// Restore conflict state from localStorage on mount
|
// Restore conflict state from localStorage on mount
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!conflictStorageKey || typeof window === 'undefined' || !currentDirectory) return;
|
if (!conflictStorageKey || typeof window === 'undefined' || !currentDirectory) return;
|
||||||
@@ -797,7 +834,7 @@ export const GitView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
setGeneratedHighlights(highlights);
|
setGeneratedHighlights(highlights);
|
||||||
|
|
||||||
toast.success('Commit message generated');
|
scrollActionPanelToBottom();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : 'Failed to generate commit message';
|
error instanceof Error ? error.message : 'Failed to generate commit message';
|
||||||
@@ -805,7 +842,7 @@ export const GitView: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsGeneratingMessage(false);
|
setIsGeneratingMessage(false);
|
||||||
}
|
}
|
||||||
}, [currentDirectory, selectedPaths, git, settingsGitmojiEnabled, gitmojiEmojis]);
|
}, [currentDirectory, selectedPaths, git, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom]);
|
||||||
|
|
||||||
const handleCreateBranch = async (branchName: string) => {
|
const handleCreateBranch = async (branchName: string) => {
|
||||||
if (!currentDirectory || !status) return;
|
if (!currentDirectory || !status) return;
|
||||||
@@ -1048,10 +1085,8 @@ export const GitView: React.FC = () => {
|
|||||||
return globalIdentity ?? null;
|
return globalIdentity ?? null;
|
||||||
}, [currentIdentity, profiles, globalIdentity]);
|
}, [currentIdentity, profiles, globalIdentity]);
|
||||||
|
|
||||||
const uniqueChangeCount = changeEntries.length;
|
|
||||||
const selectedCount = selectedPaths.size;
|
const selectedCount = selectedPaths.size;
|
||||||
const isBusy = isLoading || syncAction !== null || commitAction !== null;
|
const isBusy = isLoading || syncAction !== null || commitAction !== null;
|
||||||
const hasChanges = uniqueChangeCount > 0;
|
|
||||||
const canShowIntegrateCommitsSection = Boolean(
|
const canShowIntegrateCommitsSection = Boolean(
|
||||||
worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits
|
worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits
|
||||||
);
|
);
|
||||||
@@ -1561,6 +1596,7 @@ export const GitView: React.FC = () => {
|
|||||||
onSelectIdentity={handleApplyIdentity}
|
onSelectIdentity={handleApplyIdentity}
|
||||||
isApplyingIdentity={isSettingIdentity}
|
isApplyingIdentity={isSettingIdentity}
|
||||||
isWorktreeMode={!!worktreeMetadata}
|
isWorktreeMode={!!worktreeMetadata}
|
||||||
|
isSidebarMode={isSidebarMode}
|
||||||
onOpenHistory={() => setIsHistoryDialogOpen(true)}
|
onOpenHistory={() => setIsHistoryDialogOpen(true)}
|
||||||
onOpenBranchPicker={branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined}
|
onOpenBranchPicker={branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -1582,11 +1618,39 @@ export const GitView: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<div className="h-full min-h-0 grid grid-cols-1 xl:grid-cols-[minmax(520px,1fr)_480px]">
|
<div className="h-full min-h-0 flex flex-col">
|
||||||
<div className="min-w-0 min-h-0 h-full flex flex-col">
|
<div className={cn('min-w-0 min-h-0 h-full bg-muted/10 flex flex-col', isSidebarMode && 'border-t border-border/40')}>
|
||||||
{hasChanges ? (
|
<div className="px-3 py-3">
|
||||||
|
<AnimatedTabs<ActionTab>
|
||||||
|
value={actionTab}
|
||||||
|
onValueChange={setActionTab}
|
||||||
|
collapseLabelsOnSmall
|
||||||
|
collapseLabelsOnNarrow={isSidebarMode}
|
||||||
|
tabs={[
|
||||||
|
{ value: 'commit', label: 'Commit', icon: RiGitCommitLine },
|
||||||
|
{ value: 'branch', label: 'Update', icon: RiGitMergeLine },
|
||||||
|
{ value: 'pr', label: 'PR', icon: RiGitPullRequestLine },
|
||||||
|
{ value: 'worktree', label: 'Worktree', icon: RiSplitCellsHorizontal },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-border/40" />
|
||||||
|
|
||||||
|
<ScrollableOverlay
|
||||||
|
as={ScrollShadow}
|
||||||
|
ref={actionPanelScrollRef}
|
||||||
|
outerClassName="flex-1 min-h-0"
|
||||||
|
className="px-4 py-4"
|
||||||
|
disableHorizontal
|
||||||
|
preventOverscroll
|
||||||
|
>
|
||||||
|
{actionTab === 'commit' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(changeEntries?.length ?? 0) > 0 ? (
|
||||||
|
<>
|
||||||
<ChangesSection
|
<ChangesSection
|
||||||
variant="plain"
|
variant="plain"
|
||||||
|
maxListHeightClassName="max-h-[40vh]"
|
||||||
changeEntries={changeEntries}
|
changeEntries={changeEntries}
|
||||||
selectedPaths={selectedPaths}
|
selectedPaths={selectedPaths}
|
||||||
diffStats={status?.diffStats}
|
diffStats={status?.diffStats}
|
||||||
@@ -1597,46 +1661,7 @@ export const GitView: React.FC = () => {
|
|||||||
onViewDiff={(path) => useUIStore.getState().navigateToDiff(path)}
|
onViewDiff={(path) => useUIStore.getState().navigateToDiff(path)}
|
||||||
onRevertFile={handleRevertFile}
|
onRevertFile={handleRevertFile}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div className="flex-1 min-h-0 flex items-center justify-center px-6">
|
|
||||||
<GitEmptyState
|
|
||||||
behind={status?.behind ?? 0}
|
|
||||||
onPull={() => {
|
|
||||||
if (effectiveRemotes.length > 0) {
|
|
||||||
handleSyncAction('pull', effectiveRemotes[0]);
|
|
||||||
} else {
|
|
||||||
toast.error('No remotes configured');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
isPulling={syncAction === 'pull'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 min-h-0 h-full border-t xl:border-t-0 xl:border-l border-border/40 bg-muted/10 flex flex-col">
|
|
||||||
<div className="px-3 py-3">
|
|
||||||
<AnimatedTabs<ActionTab>
|
|
||||||
value={actionTab}
|
|
||||||
onValueChange={setActionTab}
|
|
||||||
collapseLabelsOnSmall
|
|
||||||
tabs={[
|
|
||||||
{ value: 'commit', label: 'Commit', icon: RiGitCommitLine },
|
|
||||||
{ value: 'branch', label: 'Update branch', icon: RiGitMergeLine },
|
|
||||||
{ value: 'pr', label: 'PR', icon: RiGitPullRequestLine },
|
|
||||||
{ value: 'worktree', label: 'Worktree', icon: RiSplitCellsHorizontal },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="h-px bg-border/40" />
|
|
||||||
|
|
||||||
<ScrollableOverlay
|
|
||||||
outerClassName="flex-1 min-h-0"
|
|
||||||
className="px-4 py-4"
|
|
||||||
disableHorizontal
|
|
||||||
preventOverscroll
|
|
||||||
>
|
|
||||||
{actionTab === 'commit' ? (
|
|
||||||
<CommitSection
|
<CommitSection
|
||||||
variant="plain"
|
variant="plain"
|
||||||
selectedCount={selectedCount}
|
selectedCount={selectedCount}
|
||||||
@@ -1654,6 +1679,21 @@ export const GitView: React.FC = () => {
|
|||||||
gitmojiEnabled={settingsGitmojiEnabled}
|
gitmojiEnabled={settingsGitmojiEnabled}
|
||||||
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
|
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<GitEmptyState
|
||||||
|
behind={effectiveRemotes.length > 0 ? (status?.behind ?? 0) : 0}
|
||||||
|
isPulling={syncAction === 'pull'}
|
||||||
|
onPull={() => {
|
||||||
|
const remote = effectiveRemotes[0];
|
||||||
|
if (!remote) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void handleSyncAction('pull', remote);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{actionTab === 'branch' ? (
|
{actionTab === 'branch' ? (
|
||||||
@@ -1711,6 +1751,7 @@ export const GitView: React.FC = () => {
|
|||||||
directory={pullRequestProps.directory}
|
directory={pullRequestProps.directory}
|
||||||
branch={pullRequestProps.branch}
|
branch={pullRequestProps.branch}
|
||||||
baseBranch={baseBranch}
|
baseBranch={baseBranch}
|
||||||
|
onGeneratedDescription={scrollActionPanelToBottom}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RiAddLine, RiAlertLine, RiArrowDownLine, RiArrowGoBackLine, RiArrowLeftLine, RiArrowRightLine, RiArrowUpLine, RiCheckboxCircleLine, RiCircleLine, RiCloseLine, RiCommandLine, RiDeleteBinLine, RiRestartLine } from '@remixicon/react';
|
import { RiAddLine, RiArrowDownLine, RiArrowGoBackLine, RiArrowLeftLine, RiArrowRightLine, RiArrowUpLine, RiCloseLine, RiCommandLine } from '@remixicon/react';
|
||||||
|
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|
||||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
import { type TerminalStreamEvent } from '@/lib/api/types';
|
import { type TerminalStreamEvent } from '@/lib/api/types';
|
||||||
@@ -54,6 +53,15 @@ const STREAM_OPTIONS = {
|
|||||||
connectionTimeoutMs: 10_000,
|
connectionTimeoutMs: 10_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const REHYDRATED_STREAM_OPTIONS = {
|
||||||
|
retry: {
|
||||||
|
maxRetries: 0,
|
||||||
|
initialDelayMs: 200,
|
||||||
|
maxDelayMs: 500,
|
||||||
|
},
|
||||||
|
connectionTimeoutMs: 1_500,
|
||||||
|
};
|
||||||
|
|
||||||
const getSequenceForKey = (key: MobileKey, modifier: Modifier | null): string | null => {
|
const getSequenceForKey = (key: MobileKey, modifier: Modifier | null): string | null => {
|
||||||
if (modifier) {
|
if (modifier) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
@@ -88,18 +96,6 @@ export const TerminalView: React.FC = () => {
|
|||||||
const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true;
|
const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true;
|
||||||
|
|
||||||
const effectiveDirectory = useEffectiveDirectory() ?? null;
|
const effectiveDirectory = useEffectiveDirectory() ?? null;
|
||||||
const { homeDirectory } = useDirectoryStore();
|
|
||||||
|
|
||||||
const displayDirectory = React.useMemo(() => {
|
|
||||||
if (!effectiveDirectory) return '';
|
|
||||||
if (!homeDirectory) return effectiveDirectory;
|
|
||||||
if (effectiveDirectory === homeDirectory) return '~';
|
|
||||||
if (effectiveDirectory.startsWith(homeDirectory + '/')) {
|
|
||||||
return '~' + effectiveDirectory.slice(homeDirectory.length);
|
|
||||||
}
|
|
||||||
return effectiveDirectory;
|
|
||||||
}, [effectiveDirectory, homeDirectory]);
|
|
||||||
|
|
||||||
const terminalStore = useTerminalStore();
|
const terminalStore = useTerminalStore();
|
||||||
const terminalSessions = terminalStore.sessions;
|
const terminalSessions = terminalStore.sessions;
|
||||||
const terminalHydrated = terminalStore.hasHydrated;
|
const terminalHydrated = terminalStore.hasHydrated;
|
||||||
@@ -110,7 +106,6 @@ export const TerminalView: React.FC = () => {
|
|||||||
const setTabSessionId = terminalStore.setTabSessionId;
|
const setTabSessionId = terminalStore.setTabSessionId;
|
||||||
const setConnecting = terminalStore.setConnecting;
|
const setConnecting = terminalStore.setConnecting;
|
||||||
const appendToBuffer = terminalStore.appendToBuffer;
|
const appendToBuffer = terminalStore.appendToBuffer;
|
||||||
const clearBuffer = terminalStore.clearBuffer;
|
|
||||||
|
|
||||||
const directoryTerminalState = React.useMemo(() => {
|
const directoryTerminalState = React.useMemo(() => {
|
||||||
if (!effectiveDirectory) return undefined;
|
if (!effectiveDirectory) return undefined;
|
||||||
@@ -136,7 +131,6 @@ export const TerminalView: React.FC = () => {
|
|||||||
|
|
||||||
const terminalSessionId = activeTab?.terminalSessionId ?? null;
|
const terminalSessionId = activeTab?.terminalSessionId ?? null;
|
||||||
const bufferChunks = activeTab?.bufferChunks ?? [];
|
const bufferChunks = activeTab?.bufferChunks ?? [];
|
||||||
const bufferLength = activeTab?.bufferLength ?? 0;
|
|
||||||
const isConnecting = activeTab?.isConnecting ?? false;
|
const isConnecting = activeTab?.isConnecting ?? false;
|
||||||
|
|
||||||
const [connectionError, setConnectionError] = React.useState<string | null>(null);
|
const [connectionError, setConnectionError] = React.useState<string | null>(null);
|
||||||
@@ -151,6 +145,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
const directoryRef = React.useRef<string | null>(effectiveDirectory);
|
const directoryRef = React.useRef<string | null>(effectiveDirectory);
|
||||||
const terminalControllerRef = React.useRef<TerminalController | null>(null);
|
const terminalControllerRef = React.useRef<TerminalController | null>(null);
|
||||||
const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
|
const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
|
||||||
|
const isTerminalVisibleRef = React.useRef(false);
|
||||||
const nudgeOnConnectTerminalIdRef = React.useRef<string | null>(null);
|
const nudgeOnConnectTerminalIdRef = React.useRef<string | null>(null);
|
||||||
const rehydratedTerminalIdsRef = React.useRef<Set<string>>(new Set());
|
const rehydratedTerminalIdsRef = React.useRef<Set<string>>(new Set());
|
||||||
const rehydratedSnapshotTakenRef = React.useRef(false);
|
const rehydratedSnapshotTakenRef = React.useRef(false);
|
||||||
@@ -177,15 +172,28 @@ export const TerminalView: React.FC = () => {
|
|||||||
}, [terminalHydrated]);
|
}, [terminalHydrated]);
|
||||||
|
|
||||||
const activeMainTab = useUIStore((state) => state.activeMainTab);
|
const activeMainTab = useUIStore((state) => state.activeMainTab);
|
||||||
|
const isBottomTerminalOpen = useUIStore((state) => state.isBottomTerminalOpen);
|
||||||
const isTerminalActive = activeMainTab === 'terminal';
|
const isTerminalActive = activeMainTab === 'terminal';
|
||||||
|
const isTerminalVisible = isTerminalActive || isBottomTerminalOpen;
|
||||||
|
const [hasOpenedTerminalViewport, setHasOpenedTerminalViewport] = React.useState(isTerminalVisible);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isTerminalActive || runtime.platform === 'vscode') {
|
if (!isTerminalVisible || runtime.platform === 'vscode') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
primeTerminalInputTransport();
|
primeTerminalInputTransport();
|
||||||
}, [isTerminalActive, runtime.platform]);
|
}, [isTerminalVisible, runtime.platform]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isTerminalVisible) {
|
||||||
|
setHasOpenedTerminalViewport(true);
|
||||||
|
}
|
||||||
|
}, [isTerminalVisible]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
isTerminalVisibleRef.current = isTerminalVisible;
|
||||||
|
}, [isTerminalVisible]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
terminalIdRef.current = terminalSessionId;
|
terminalIdRef.current = terminalSessionId;
|
||||||
@@ -226,7 +234,12 @@ export const TerminalView: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const startStream = React.useCallback(
|
const startStream = React.useCallback(
|
||||||
(directory: string, tabId: string, terminalId: string) => {
|
(
|
||||||
|
directory: string,
|
||||||
|
tabId: string,
|
||||||
|
terminalId: string,
|
||||||
|
streamOptions = STREAM_OPTIONS
|
||||||
|
) => {
|
||||||
if (activeTerminalIdRef.current === terminalId) {
|
if (activeTerminalIdRef.current === terminalId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -313,7 +326,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
STREAM_OPTIONS
|
streamOptions
|
||||||
);
|
);
|
||||||
|
|
||||||
streamCleanupRef.current = () => {
|
streamCleanupRef.current = () => {
|
||||||
@@ -327,7 +340,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
if (!terminalHydrated) {
|
if (!terminalHydrated || !hasOpenedTerminalViewport) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +381,9 @@ export const TerminalView: React.FC = () => {
|
|||||||
(tab?.bufferLength ?? 0) === 0 &&
|
(tab?.bufferLength ?? 0) === 0 &&
|
||||||
(tab?.bufferChunks?.length ?? 0) === 0;
|
(tab?.bufferChunks?.length ?? 0) === 0;
|
||||||
|
|
||||||
|
const isRehydratedSession =
|
||||||
|
Boolean(terminalId) && rehydratedTerminalIdsRef.current.has(terminalId as string);
|
||||||
|
|
||||||
if (!terminalId) {
|
if (!terminalId) {
|
||||||
setConnectionError(null);
|
setConnectionError(null);
|
||||||
setIsFatalError(false);
|
setIsFatalError(false);
|
||||||
@@ -412,11 +428,19 @@ export const TerminalView: React.FC = () => {
|
|||||||
|
|
||||||
terminalIdRef.current = terminalId;
|
terminalIdRef.current = terminalId;
|
||||||
|
|
||||||
if (shouldNudgeExisting) {
|
if (isRehydratedSession) {
|
||||||
nudgeOnConnectTerminalIdRef.current = terminalId;
|
|
||||||
rehydratedTerminalIdsRef.current.delete(terminalId);
|
rehydratedTerminalIdsRef.current.delete(terminalId);
|
||||||
}
|
}
|
||||||
startStream(directory, tabId, terminalId);
|
|
||||||
|
if (shouldNudgeExisting) {
|
||||||
|
nudgeOnConnectTerminalIdRef.current = terminalId;
|
||||||
|
}
|
||||||
|
startStream(
|
||||||
|
directory,
|
||||||
|
tabId,
|
||||||
|
terminalId,
|
||||||
|
isRehydratedSession ? REHYDRATED_STREAM_OPTIONS : STREAM_OPTIONS
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
void ensureSession();
|
void ensureSession();
|
||||||
@@ -431,6 +455,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
effectiveDirectory,
|
effectiveDirectory,
|
||||||
terminalSessionId,
|
terminalSessionId,
|
||||||
activeTabId,
|
activeTabId,
|
||||||
|
hasOpenedTerminalViewport,
|
||||||
enableTabs,
|
enableTabs,
|
||||||
terminalHydrated,
|
terminalHydrated,
|
||||||
ensureDirectory,
|
ensureDirectory,
|
||||||
@@ -441,6 +466,25 @@ export const TerminalView: React.FC = () => {
|
|||||||
terminal,
|
terminal,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isTerminalVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
terminalControllerRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rafId = window.requestAnimationFrame(() => {
|
||||||
|
terminalControllerRef.current?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
|
}, [activeTabId, isTerminalVisible]);
|
||||||
|
|
||||||
const handleRestart = React.useCallback(async () => {
|
const handleRestart = React.useCallback(async () => {
|
||||||
if (!effectiveDirectory) return;
|
if (!effectiveDirectory) return;
|
||||||
if (isRestarting) return;
|
if (isRestarting) return;
|
||||||
@@ -472,21 +516,6 @@ export const TerminalView: React.FC = () => {
|
|||||||
await handleRestart();
|
await handleRestart();
|
||||||
}, [handleRestart]);
|
}, [handleRestart]);
|
||||||
|
|
||||||
const handleClear = React.useCallback(() => {
|
|
||||||
if (!effectiveDirectory) return;
|
|
||||||
if (!activeTabId) return;
|
|
||||||
clearBuffer(effectiveDirectory, activeTabId);
|
|
||||||
terminalControllerRef.current?.clear();
|
|
||||||
terminalControllerRef.current?.focus();
|
|
||||||
|
|
||||||
const terminalId = terminalIdRef.current;
|
|
||||||
if (terminalId) {
|
|
||||||
void terminal.sendInput(terminalId, '\u000c').catch((error) => {
|
|
||||||
setConnectionError(error instanceof Error ? error.message : 'Failed to refresh prompt');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [activeTabId, clearBuffer, effectiveDirectory, setConnectionError, terminal]);
|
|
||||||
|
|
||||||
const handleCreateTab = React.useCallback(() => {
|
const handleCreateTab = React.useCallback(() => {
|
||||||
if (!effectiveDirectory) return;
|
if (!effectiveDirectory) return;
|
||||||
const tabId = createTab(effectiveDirectory);
|
const tabId = createTab(effectiveDirectory);
|
||||||
@@ -565,6 +594,9 @@ export const TerminalView: React.FC = () => {
|
|||||||
const handleViewportResize = React.useCallback(
|
const handleViewportResize = React.useCallback(
|
||||||
(cols: number, rows: number) => {
|
(cols: number, rows: number) => {
|
||||||
lastViewportSizeRef.current = { cols, rows };
|
lastViewportSizeRef.current = { cols, rows };
|
||||||
|
if (!isTerminalVisibleRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const terminalId = terminalIdRef.current;
|
const terminalId = terminalIdRef.current;
|
||||||
if (!terminalId) return;
|
if (!terminalId) return;
|
||||||
void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {
|
void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {
|
||||||
@@ -705,7 +737,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
const viewportSessionKey = terminalSessionId ?? terminalSessionKey;
|
const viewportSessionKey = terminalSessionId ?? terminalSessionKey;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isTerminalActive) {
|
if (!isTerminalVisible) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const controller = terminalControllerRef.current;
|
const controller = terminalControllerRef.current;
|
||||||
@@ -727,19 +759,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
fitOnce();
|
fitOnce();
|
||||||
}, [isTerminalActive, terminalSessionKey, terminalSessionId]);
|
}, [isTerminalVisible, terminalSessionKey, terminalSessionId]);
|
||||||
|
|
||||||
const isReconnecting = connectionError?.includes('Reconnecting');
|
|
||||||
|
|
||||||
const statusIcon = connectionError
|
|
||||||
? isReconnecting
|
|
||||||
? <RiAlertLine size={20} className="text-[color:var(--status-warning)]" />
|
|
||||||
: <RiCloseLine size={20} className="text-[color:var(--status-error)]" />
|
|
||||||
: terminalSessionId && !isConnecting && !isRestarting
|
|
||||||
? <RiCheckboxCircleLine size={20} className="text-[color:var(--status-success)]" />
|
|
||||||
: isConnecting || isRestarting
|
|
||||||
? <RiCircleLine size={20} className="text-[color:var(--status-warning)] animate-pulse" />
|
|
||||||
: <RiCircleLine size={20} className="text-[var(--surface-muted-foreground)]" />;
|
|
||||||
|
|
||||||
if (!hasActiveContext) {
|
if (!hasActiveContext) {
|
||||||
return (
|
return (
|
||||||
@@ -764,97 +784,9 @@ export const TerminalView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting;
|
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting;
|
||||||
|
const shouldRenderViewport = isMobile ? isTerminalVisible : hasOpenedTerminalViewport;
|
||||||
return (
|
const quickKeysControls = (
|
||||||
<div className="flex h-full flex-col overflow-hidden bg-[var(--surface-background)]">
|
<>
|
||||||
<div className="px-3 py-2 text-xs bg-[var(--surface-background)]">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex min-w-0 items-center gap-2 text-muted-foreground">
|
|
||||||
<span className="truncate font-mono text-foreground/90">{displayDirectory}</span>
|
|
||||||
</div>
|
|
||||||
{isMobile ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{statusIcon}
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="default"
|
|
||||||
className="h-7 px-2 py-0"
|
|
||||||
onClick={handleClear}
|
|
||||||
disabled={!bufferLength}
|
|
||||||
title="Clear output"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<RiDeleteBinLine size={16} />
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="default"
|
|
||||||
className="h-7 px-2 py-0"
|
|
||||||
onClick={handleRestart}
|
|
||||||
disabled={isRestarting}
|
|
||||||
title="Restart terminal"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<RiRestartLine size={16} className={cn((isConnecting || isRestarting) && 'animate-spin')} />
|
|
||||||
Restart
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{enableTabs && directoryTerminalState ? (
|
|
||||||
<div className="mt-2 flex items-center gap-1 overflow-x-auto pb-1">
|
|
||||||
{directoryTerminalState.tabs.map((tab) => {
|
|
||||||
const isActive = tab.id === activeTabId;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tab.id}
|
|
||||||
className={cn(
|
|
||||||
'group flex items-center gap-1 rounded-md border px-2 py-1 text-xs whitespace-nowrap',
|
|
||||||
isActive
|
|
||||||
? 'bg-[var(--interactive-selection)] border-[var(--primary-muted)] text-[var(--interactive-selection-foreground)]'
|
|
||||||
: 'bg-transparent border-[var(--interactive-border)] text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSelectTab(tab.id)}
|
|
||||||
className="max-w-[10rem] truncate text-left"
|
|
||||||
title={tab.label}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'rounded-sm p-0.5 text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
|
|
||||||
!isActive && 'opacity-0 group-hover:opacity-100'
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleCloseTab(tab.id);
|
|
||||||
}}
|
|
||||||
title="Close tab"
|
|
||||||
>
|
|
||||||
<RiCloseLine size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCreateTab}
|
|
||||||
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md border border-[var(--interactive-border)] bg-transparent text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
|
|
||||||
title="New tab"
|
|
||||||
>
|
|
||||||
<RiAddLine size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{showQuickKeys ? (
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -953,6 +885,76 @@ export const TerminalView: React.FC = () => {
|
|||||||
<RiArrowGoBackLine size={16} />
|
<RiArrowGoBackLine size={16} />
|
||||||
<span className="sr-only">Enter</span>
|
<span className="sr-only">Enter</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden bg-[var(--surface-background)]">
|
||||||
|
<div className="px-5 py-2 text-xs bg-[var(--surface-background)]">
|
||||||
|
{enableTabs && directoryTerminalState ? (
|
||||||
|
<div className="mt-2 pl-1 pr-1 flex items-center gap-2">
|
||||||
|
<div className="min-w-0 flex-1 overflow-x-auto pb-1">
|
||||||
|
<div className="flex w-max items-center gap-1 pr-1">
|
||||||
|
{directoryTerminalState.tabs.map((tab) => {
|
||||||
|
const isActive = tab.id === activeTabId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-1 rounded-md border px-2 py-1 text-xs whitespace-nowrap',
|
||||||
|
isActive
|
||||||
|
? 'bg-[var(--interactive-selection)] border-[var(--primary-muted)] text-[var(--interactive-selection-foreground)]'
|
||||||
|
: 'bg-transparent border-[var(--interactive-border)] text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectTab(tab.id)}
|
||||||
|
className="max-w-[10rem] truncate text-left"
|
||||||
|
title={tab.label}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'rounded-sm p-0.5 text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
|
||||||
|
!isActive && 'opacity-0 group-hover:opacity-100'
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCloseTab(tab.id);
|
||||||
|
}}
|
||||||
|
title="Close tab"
|
||||||
|
>
|
||||||
|
<RiCloseLine size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreateTab}
|
||||||
|
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md border border-[var(--interactive-border)] bg-transparent text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
|
||||||
|
title="New tab"
|
||||||
|
>
|
||||||
|
<RiAddLine size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isMobile && showQuickKeys ? (
|
||||||
|
<div className="flex shrink-0 items-center gap-1 overflow-x-auto pb-1">
|
||||||
|
{quickKeysControls}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showQuickKeys && (isMobile || !enableTabs || !directoryTerminalState) ? (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||||
|
{quickKeysControls}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -962,8 +964,8 @@ export const TerminalView: React.FC = () => {
|
|||||||
style={{ backgroundColor: xtermTheme.background }}
|
style={{ backgroundColor: xtermTheme.background }}
|
||||||
data-keyboard-avoid="true"
|
data-keyboard-avoid="true"
|
||||||
>
|
>
|
||||||
<div className="h-full w-full box-border px-3 pt-3 pb-4">
|
<div className="h-full w-full box-border pl-7 pr-5 pt-3 pb-4">
|
||||||
{isTerminalActive ? (
|
{shouldRenderViewport ? (
|
||||||
isMobile ? (
|
isMobile ? (
|
||||||
<TerminalViewport
|
<TerminalViewport
|
||||||
key={viewportSessionKey}
|
key={viewportSessionKey}
|
||||||
@@ -978,6 +980,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
fontFamily={resolvedFontStack}
|
fontFamily={resolvedFontStack}
|
||||||
fontSize={terminalFontSize}
|
fontSize={terminalFontSize}
|
||||||
enableTouchScroll={hasTouchInput}
|
enableTouchScroll={hasTouchInput}
|
||||||
|
autoFocus={isTerminalVisible}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ScrollableOverlay outerClassName="h-full" className="h-full w-full" disableHorizontal>
|
<ScrollableOverlay outerClassName="h-full" className="h-full w-full" disableHorizontal>
|
||||||
@@ -994,6 +997,7 @@ export const TerminalView: React.FC = () => {
|
|||||||
fontFamily={resolvedFontStack}
|
fontFamily={resolvedFontStack}
|
||||||
fontSize={terminalFontSize}
|
fontSize={terminalFontSize}
|
||||||
enableTouchScroll={hasTouchInput}
|
enableTouchScroll={hasTouchInput}
|
||||||
|
autoFocus={isTerminalVisible}
|
||||||
/>
|
/>
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface BranchSelectorProps {
|
|||||||
onCheckout: (branch: string) => void;
|
onCheckout: (branch: string) => void;
|
||||||
onCreate: (name: string) => Promise<void>;
|
onCreate: (name: string) => Promise<void>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
tooltipDelayMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizeBranchNameInput = (value: string): string => {
|
const sanitizeBranchNameInput = (value: string): string => {
|
||||||
@@ -59,6 +60,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
|||||||
onCheckout,
|
onCheckout,
|
||||||
onCreate,
|
onCreate,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
tooltipDelayMs = 1000,
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = React.useState(false);
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
const [search, setSearch] = React.useState('');
|
const [search, setSearch] = React.useState('');
|
||||||
@@ -127,17 +129,17 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Tooltip delayDuration={1000}>
|
<Tooltip delayDuration={tooltipDelayMs}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5 px-2 py-1 h-8"
|
className="h-8 min-w-0 max-w-full justify-start gap-1.5 px-2 py-1"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<RiGitBranchLine className="size-4 text-primary" />
|
<RiGitBranchLine className="size-4 text-primary" />
|
||||||
<span className="max-w-[140px] truncate font-medium">
|
<span className="min-w-0 truncate font-medium text-left">
|
||||||
{currentBranch || 'Detached HEAD'}
|
{currentBranch || 'Detached HEAD'}
|
||||||
</span>
|
</span>
|
||||||
<RiArrowDownSLine className="size-4 opacity-60" />
|
<RiArrowDownSLine className="size-4 opacity-60" />
|
||||||
@@ -145,7 +147,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent sideOffset={8}>
|
<TooltipContent sideOffset={8}>
|
||||||
Switch branch ({localBranches.length} local · {remoteBranches.length} remote)
|
Current branch
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||||
|
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||||
import { ChangeRow } from './ChangeRow';
|
import { ChangeRow } from './ChangeRow';
|
||||||
import type { GitStatus } from '@/lib/api/types';
|
import type { GitStatus } from '@/lib/api/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface ChangesSectionProps {
|
interface ChangesSectionProps {
|
||||||
changeEntries: GitStatus['files'];
|
changeEntries: GitStatus['files'];
|
||||||
@@ -15,6 +17,7 @@ interface ChangesSectionProps {
|
|||||||
onViewDiff: (path: string) => void;
|
onViewDiff: (path: string) => void;
|
||||||
onRevertFile: (path: string) => void;
|
onRevertFile: (path: string) => void;
|
||||||
variant?: 'framed' | 'plain';
|
variant?: 'framed' | 'plain';
|
||||||
|
maxListHeightClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||||
@@ -28,7 +31,9 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
|||||||
onViewDiff,
|
onViewDiff,
|
||||||
onRevertFile,
|
onRevertFile,
|
||||||
variant = 'framed',
|
variant = 'framed',
|
||||||
|
maxListHeightClassName,
|
||||||
}) => {
|
}) => {
|
||||||
|
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const selectedCount = selectedPaths.size;
|
const selectedCount = selectedPaths.size;
|
||||||
const totalCount = changeEntries.length;
|
const totalCount = changeEntries.length;
|
||||||
|
|
||||||
@@ -43,7 +48,7 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
|||||||
const scrollOuterClassName =
|
const scrollOuterClassName =
|
||||||
variant === 'framed'
|
variant === 'framed'
|
||||||
? 'flex-1 min-h-0 max-h-[30vh]'
|
? 'flex-1 min-h-0 max-h-[30vh]'
|
||||||
: 'flex-1 min-h-0';
|
: `flex-1 min-h-0 ${maxListHeightClassName ?? ''}`.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={containerClassName}>
|
<section className={containerClassName}>
|
||||||
@@ -76,7 +81,11 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<ScrollableOverlay outerClassName={scrollOuterClassName} className="w-full">
|
<div className={cn('relative flex flex-col min-h-0 w-full overflow-hidden', scrollOuterClassName)}>
|
||||||
|
<ScrollShadow
|
||||||
|
ref={scrollRef}
|
||||||
|
className="overlay-scrollbar-target overlay-scrollbar-container flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden"
|
||||||
|
>
|
||||||
<ul className="divide-y divide-border/60">
|
<ul className="divide-y divide-border/60">
|
||||||
{changeEntries.map((file) => (
|
{changeEntries.map((file) => (
|
||||||
<ChangeRow
|
<ChangeRow
|
||||||
@@ -91,7 +100,9 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</ScrollableOverlay>
|
</ScrollShadow>
|
||||||
|
<OverlayScrollbar containerRef={scrollRef} disableHorizontal />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
RiArrowUpLine,
|
|
||||||
RiArrowDownLine,
|
|
||||||
RiArrowDownSLine,
|
RiArrowDownSLine,
|
||||||
|
RiCheckLine,
|
||||||
RiLoader4Line,
|
RiLoader4Line,
|
||||||
RiGitBranchLine,
|
RiGitBranchLine,
|
||||||
RiGitRepositoryLine,
|
RiGitRepositoryLine,
|
||||||
@@ -26,6 +25,7 @@ import { BranchSelector } from './BranchSelector';
|
|||||||
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
|
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
|
||||||
import { SyncActions } from './SyncActions';
|
import { SyncActions } from './SyncActions';
|
||||||
import type { GitStatus, GitIdentityProfile, GitRemote } from '@/lib/api/types';
|
import type { GitStatus, GitIdentityProfile, GitRemote } from '@/lib/api/types';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
|
||||||
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ interface GitHeaderProps {
|
|||||||
onSelectIdentity: (profile: GitIdentityProfile) => void;
|
onSelectIdentity: (profile: GitIdentityProfile) => void;
|
||||||
isApplyingIdentity: boolean;
|
isApplyingIdentity: boolean;
|
||||||
isWorktreeMode: boolean;
|
isWorktreeMode: boolean;
|
||||||
|
isSidebarMode?: boolean;
|
||||||
onOpenHistory?: () => void;
|
onOpenHistory?: () => void;
|
||||||
onOpenBranchPicker?: () => void;
|
onOpenBranchPicker?: () => void;
|
||||||
}
|
}
|
||||||
@@ -103,6 +104,8 @@ interface IdentityDropdownProps {
|
|||||||
identities: GitIdentityProfile[];
|
identities: GitIdentityProfile[];
|
||||||
onSelect: (profile: GitIdentityProfile) => void;
|
onSelect: (profile: GitIdentityProfile) => void;
|
||||||
isApplying: boolean;
|
isApplying: boolean;
|
||||||
|
tooltipDelayMs?: number;
|
||||||
|
iconOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IdentityDropdown: React.FC<IdentityDropdownProps> = ({
|
const IdentityDropdown: React.FC<IdentityDropdownProps> = ({
|
||||||
@@ -110,18 +113,20 @@ const IdentityDropdown: React.FC<IdentityDropdownProps> = ({
|
|||||||
identities,
|
identities,
|
||||||
onSelect,
|
onSelect,
|
||||||
isApplying,
|
isApplying,
|
||||||
|
tooltipDelayMs = 1000,
|
||||||
|
iconOnly = false,
|
||||||
}) => {
|
}) => {
|
||||||
const isDisabled = isApplying || identities.length === 0;
|
const isDisabled = isApplying || identities.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<Tooltip delayDuration={1000}>
|
<Tooltip delayDuration={tooltipDelayMs}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5 px-2 py-1 h-8 typography-ui-label"
|
className="h-8 min-w-0 max-w-[15rem] justify-start gap-1.5 px-2 py-1 typography-ui-label"
|
||||||
style={{ color: getIdentityColor(activeProfile?.color) }}
|
style={{ color: getIdentityColor(activeProfile?.color) }}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
@@ -134,21 +139,16 @@ const IdentityDropdown: React.FC<IdentityDropdownProps> = ({
|
|||||||
className="size-4"
|
className="size-4"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="max-w-[120px] truncate hidden sm:inline">
|
{!iconOnly && (
|
||||||
|
<span className="git-identity-label min-w-0 flex-1 truncate text-left">
|
||||||
{activeProfile?.name || 'No identity'}
|
{activeProfile?.name || 'No identity'}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<RiArrowDownSLine className="size-4 opacity-60" />
|
<RiArrowDownSLine className="size-4 opacity-60" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent sideOffset={8} className="space-y-1">
|
<TooltipContent sideOffset={8}>Git identity</TooltipContent>
|
||||||
<p className="typography-ui-label text-foreground">
|
|
||||||
{activeProfile?.userName || 'Unknown user'}
|
|
||||||
</p>
|
|
||||||
<p className="typography-meta text-muted-foreground">
|
|
||||||
{activeProfile?.userEmail || 'No email configured'}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenuContent align="end" className="w-64">
|
<DropdownMenuContent align="end" className="w-64">
|
||||||
{identities.length === 0 ? (
|
{identities.length === 0 ? (
|
||||||
@@ -158,7 +158,9 @@ const IdentityDropdown: React.FC<IdentityDropdownProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
identities.map((profile) => (
|
identities.map((profile) => {
|
||||||
|
const isSelected = activeProfile?.id === profile.id;
|
||||||
|
return (
|
||||||
<DropdownMenuItem key={profile.id} onSelect={() => onSelect(profile)}>
|
<DropdownMenuItem key={profile.id} onSelect={() => onSelect(profile)}>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<IdentityIcon
|
<IdentityIcon
|
||||||
@@ -166,7 +168,7 @@ const IdentityDropdown: React.FC<IdentityDropdownProps> = ({
|
|||||||
colorToken={profile.color}
|
colorToken={profile.color}
|
||||||
className="size-4"
|
className="size-4"
|
||||||
/>
|
/>
|
||||||
<span className="flex flex-col">
|
<span className="flex min-w-0 flex-col">
|
||||||
<span className="typography-ui-label text-foreground">
|
<span className="typography-ui-label text-foreground">
|
||||||
{profile.name}
|
{profile.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -174,9 +176,13 @@ const IdentityDropdown: React.FC<IdentityDropdownProps> = ({
|
|||||||
{profile.userEmail}
|
{profile.userEmail}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
{isSelected ? (
|
||||||
|
<RiCheckLine className="ml-auto size-4 text-foreground" />
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -201,15 +207,120 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
onSelectIdentity,
|
onSelectIdentity,
|
||||||
isApplyingIdentity,
|
isApplyingIdentity,
|
||||||
isWorktreeMode,
|
isWorktreeMode,
|
||||||
|
isSidebarMode = false,
|
||||||
onOpenHistory,
|
onOpenHistory,
|
||||||
onOpenBranchPicker,
|
onOpenBranchPicker,
|
||||||
}) => {
|
}) => {
|
||||||
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const useTwoRowHeader = isSidebarMode || isMobile;
|
||||||
|
|
||||||
|
const managementButtons = (
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{onOpenBranchPicker ? (
|
||||||
|
<Tooltip delayDuration={useTwoRowHeader ? 300 : 1000}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={isSidebarMode ? 'h-8 w-8 px-0' : 'gap-1.5 px-2 py-1 h-8 typography-ui-label'}
|
||||||
|
onClick={onOpenBranchPicker}
|
||||||
|
>
|
||||||
|
<RiGitRepositoryLine className="size-4" />
|
||||||
|
{!isSidebarMode && <span className="git-header-label">Manage branches</span>}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent sideOffset={8}>Manage branches</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{onOpenHistory ? (
|
||||||
|
<Tooltip delayDuration={useTwoRowHeader ? 300 : 1000}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={isSidebarMode ? 'h-8 w-8 px-0' : 'gap-1.5 px-2 py-1 h-8 typography-ui-label'}
|
||||||
|
onClick={onOpenHistory}
|
||||||
|
>
|
||||||
|
<RiHistoryLine className="size-4" />
|
||||||
|
{!isSidebarMode && <span className="git-header-label">History</span>}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent sideOffset={8}>History</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncButtons = (
|
||||||
|
<SyncActions
|
||||||
|
syncAction={syncAction}
|
||||||
|
remotes={remotes}
|
||||||
|
onFetch={onFetch}
|
||||||
|
onPull={onPull}
|
||||||
|
onPush={onPush}
|
||||||
|
disabled={!status}
|
||||||
|
iconOnly={isSidebarMode}
|
||||||
|
tooltipDelayMs={useTwoRowHeader ? 300 : 1000}
|
||||||
|
aheadCount={status.ahead}
|
||||||
|
behindCount={status.behind}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const identityControl = (
|
||||||
|
<IdentityDropdown
|
||||||
|
activeProfile={activeIdentityProfile}
|
||||||
|
identities={availableIdentities}
|
||||||
|
onSelect={onSelectIdentity}
|
||||||
|
isApplying={isApplyingIdentity}
|
||||||
|
tooltipDelayMs={useTwoRowHeader ? 300 : 1000}
|
||||||
|
iconOnly={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (useTwoRowHeader) {
|
||||||
return (
|
return (
|
||||||
<header className="flex flex-wrap items-center gap-2 border-b border-border/40 px-3 py-2 bg-background">
|
<header className="@container/git-header border-b border-border/40 px-3 py-2 bg-background">
|
||||||
|
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{isWorktreeMode ? (
|
||||||
|
<WorktreeBranchDisplay
|
||||||
|
currentBranch={status.current}
|
||||||
|
onRename={onRenameBranch}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BranchSelector
|
||||||
|
currentBranch={status.current}
|
||||||
|
localBranches={localBranches}
|
||||||
|
remoteBranches={remoteBranches}
|
||||||
|
branchInfo={branchInfo}
|
||||||
|
onCheckout={onCheckoutBranch}
|
||||||
|
onCreate={onCreateBranch}
|
||||||
|
tooltipDelayMs={useTwoRowHeader ? 300 : 1000}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex items-center justify-between gap-2 min-w-0">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-1">
|
||||||
|
{syncButtons}
|
||||||
|
{managementButtons}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 max-w-[45%]">{identityControl}</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="@container/git-header flex items-center gap-2 border-b border-border/40 px-3 py-2 bg-background">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||||
{isWorktreeMode ? (
|
{isWorktreeMode ? (
|
||||||
<WorktreeBranchDisplay
|
<WorktreeBranchDisplay
|
||||||
currentBranch={status.current}
|
currentBranch={status.current}
|
||||||
@@ -226,81 +337,13 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(Boolean(status.tracking) || status.ahead > 0 || status.behind > 0) && (
|
<div className="shrink-0">{syncButtons}</div>
|
||||||
<Tooltip delayDuration={800}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center gap-2 px-1.5 typography-meta text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-0.5">
|
|
||||||
<RiArrowUpLine className="size-3.5 text-primary/70" />
|
|
||||||
<span className="font-semibold text-foreground">{status.ahead}</span>
|
|
||||||
</span>
|
|
||||||
{Boolean(status.tracking) && (
|
|
||||||
<span className="flex items-center gap-0.5">
|
|
||||||
<RiArrowDownLine className="size-3.5 text-primary/70" />
|
|
||||||
<span className="font-semibold text-foreground">{status.behind}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent sideOffset={8}>
|
|
||||||
{status.tracking
|
|
||||||
? `Upstream: ${status.tracking}`
|
|
||||||
: 'Unpublished commits (no upstream set yet)'}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SyncActions
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
syncAction={syncAction}
|
{managementButtons}
|
||||||
remotes={remotes}
|
{identityControl}
|
||||||
onFetch={onFetch}
|
</div>
|
||||||
onPull={onPull}
|
|
||||||
onPush={onPush}
|
|
||||||
disabled={!status}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
{onOpenBranchPicker ? (
|
|
||||||
<Tooltip delayDuration={1000}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="gap-1.5 px-2 py-1 h-8 typography-ui-label"
|
|
||||||
onClick={onOpenBranchPicker}
|
|
||||||
>
|
|
||||||
<RiGitRepositoryLine className="size-4" />
|
|
||||||
<span className="hidden sm:inline">Manage branches</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent sideOffset={8}>Manage branches</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{onOpenHistory ? (
|
|
||||||
<Tooltip delayDuration={1000}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="gap-1.5 px-2 py-1 h-8 typography-ui-label"
|
|
||||||
onClick={onOpenHistory}
|
|
||||||
>
|
|
||||||
<RiHistoryLine className="size-4" />
|
|
||||||
<span className="hidden sm:inline">History</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent sideOffset={8}>Show commit history</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<IdentityDropdown
|
|
||||||
activeProfile={activeIdentityProfile}
|
|
||||||
identities={availableIdentities}
|
|
||||||
onSelect={onSelectIdentity}
|
|
||||||
isApplying={isApplyingIdentity}
|
|
||||||
/>
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,10 @@ interface SyncActionsProps {
|
|||||||
onPull: (remote: GitRemote) => void;
|
onPull: (remote: GitRemote) => void;
|
||||||
onPush: (remote: GitRemote) => void;
|
onPush: (remote: GitRemote) => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
iconOnly?: boolean;
|
||||||
|
tooltipDelayMs?: number;
|
||||||
|
aheadCount?: number;
|
||||||
|
behindCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SyncActions: React.FC<SyncActionsProps> = ({
|
export const SyncActions: React.FC<SyncActionsProps> = ({
|
||||||
@@ -33,6 +37,10 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
|
|||||||
onPull,
|
onPull,
|
||||||
onPush,
|
onPush,
|
||||||
disabled,
|
disabled,
|
||||||
|
iconOnly = false,
|
||||||
|
tooltipDelayMs = 1000,
|
||||||
|
aheadCount = 0,
|
||||||
|
behindCount = 0,
|
||||||
}) => {
|
}) => {
|
||||||
const hasNoRemotes = remotes.length === 0;
|
const hasNoRemotes = remotes.length === 0;
|
||||||
const isDisabled = disabled || syncAction !== null || hasNoRemotes;
|
const isDisabled = disabled || syncAction !== null || hasNoRemotes;
|
||||||
@@ -65,23 +73,34 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
|
|||||||
loadingIcon: React.ReactNode,
|
loadingIcon: React.ReactNode,
|
||||||
label: string,
|
label: string,
|
||||||
onClick: () => void,
|
onClick: () => void,
|
||||||
tooltipText: string
|
tooltipText: string,
|
||||||
|
counter?: number
|
||||||
) => {
|
) => {
|
||||||
const button = (
|
const button = (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 px-2"
|
className={iconOnly ? 'relative h-8 w-8 px-0' : 'h-8 px-2'}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
{syncAction === action ? loadingIcon : icon}
|
{syncAction === action ? loadingIcon : icon}
|
||||||
<span className="hidden sm:inline">{label}</span>
|
{!iconOnly && <span className="git-header-label">{label}</span>}
|
||||||
|
{!iconOnly && typeof counter === 'number' && counter > 0 ? (
|
||||||
|
<span className="rounded-sm bg-interactive-selection/40 px-1 text-[10px] leading-4 text-foreground tabular-nums">
|
||||||
|
{counter}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{iconOnly && typeof counter === 'number' && counter > 0 ? (
|
||||||
|
<span className="absolute -right-1 -top-1 min-w-[1rem] rounded-full bg-interactive-selection px-1 text-[10px] leading-4 text-interactive-selection-foreground tabular-nums">
|
||||||
|
{counter}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip delayDuration={1000}>
|
<Tooltip delayDuration={tooltipDelayMs}>
|
||||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
<TooltipContent sideOffset={8}>{tooltipText}</TooltipContent>
|
<TooltipContent sideOffset={8}>{tooltipText}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -94,21 +113,32 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
|
|||||||
loadingIcon: React.ReactNode,
|
loadingIcon: React.ReactNode,
|
||||||
label: string,
|
label: string,
|
||||||
onSelect: (remote: GitRemote) => void,
|
onSelect: (remote: GitRemote) => void,
|
||||||
tooltipText: string
|
tooltipText: string,
|
||||||
|
counter?: number
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<Tooltip delayDuration={1000}>
|
<Tooltip delayDuration={tooltipDelayMs}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 px-2"
|
className={iconOnly ? 'relative h-8 w-8 px-0' : 'h-8 px-2'}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
{syncAction === action ? loadingIcon : icon}
|
{syncAction === action ? loadingIcon : icon}
|
||||||
<span className="hidden sm:inline">{label}</span>
|
{!iconOnly && <span className="git-header-label">{label}</span>}
|
||||||
|
{!iconOnly && typeof counter === 'number' && counter > 0 ? (
|
||||||
|
<span className="rounded-sm bg-interactive-selection/40 px-1 text-[10px] leading-4 text-foreground tabular-nums">
|
||||||
|
{counter}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{iconOnly && typeof counter === 'number' && counter > 0 ? (
|
||||||
|
<span className="absolute -right-1 -top-1 min-w-[1rem] rounded-full bg-interactive-selection px-1 text-[10px] leading-4 text-interactive-selection-foreground tabular-nums">
|
||||||
|
{counter}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -159,7 +189,8 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
|
|||||||
<RiLoader4Line className="size-4 animate-spin" />,
|
<RiLoader4Line className="size-4 animate-spin" />,
|
||||||
'Pull',
|
'Pull',
|
||||||
onPull,
|
onPull,
|
||||||
'Pull changes'
|
behindCount > 0 ? `Pull changes (${behindCount} behind)` : 'Pull changes',
|
||||||
|
behindCount
|
||||||
)
|
)
|
||||||
: renderButton(
|
: renderButton(
|
||||||
'pull',
|
'pull',
|
||||||
@@ -167,7 +198,8 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
|
|||||||
<RiLoader4Line className="size-4 animate-spin" />,
|
<RiLoader4Line className="size-4 animate-spin" />,
|
||||||
'Pull',
|
'Pull',
|
||||||
handlePull,
|
handlePull,
|
||||||
'Pull changes'
|
behindCount > 0 ? `Pull changes (${behindCount} behind)` : 'Pull changes',
|
||||||
|
behindCount
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasMultipleRemotes
|
{hasMultipleRemotes
|
||||||
@@ -177,7 +209,8 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
|
|||||||
<RiLoader4Line className="size-4 animate-spin" />,
|
<RiLoader4Line className="size-4 animate-spin" />,
|
||||||
'Push',
|
'Push',
|
||||||
onPush,
|
onPush,
|
||||||
'Push changes'
|
aheadCount > 0 ? `Push changes (${aheadCount} ahead)` : 'Push changes',
|
||||||
|
aheadCount
|
||||||
)
|
)
|
||||||
: renderButton(
|
: renderButton(
|
||||||
'push',
|
'push',
|
||||||
@@ -185,7 +218,8 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
|
|||||||
<RiLoader4Line className="size-4 animate-spin" />,
|
<RiLoader4Line className="size-4 animate-spin" />,
|
||||||
'Push',
|
'Push',
|
||||||
handlePush,
|
handlePush,
|
||||||
'Push changes'
|
aheadCount > 0 ? `Push changes (${aheadCount} ahead)` : 'Push changes',
|
||||||
|
aheadCount
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
interface WorktreeBranchDisplayProps {
|
interface WorktreeBranchDisplayProps {
|
||||||
currentBranch: string | null | undefined;
|
currentBranch: string | null | undefined;
|
||||||
onRename?: (oldName: string, newName: string) => Promise<void>;
|
onRename?: (oldName: string, newName: string) => Promise<void>;
|
||||||
|
showEditButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizeBranchNameInput = (value: string): string => {
|
const sanitizeBranchNameInput = (value: string): string => {
|
||||||
@@ -23,6 +24,7 @@ const sanitizeBranchNameInput = (value: string): string => {
|
|||||||
export const WorktreeBranchDisplay: React.FC<WorktreeBranchDisplayProps> = ({
|
export const WorktreeBranchDisplay: React.FC<WorktreeBranchDisplayProps> = ({
|
||||||
currentBranch,
|
currentBranch,
|
||||||
onRename,
|
onRename,
|
||||||
|
showEditButton = true,
|
||||||
}) => {
|
}) => {
|
||||||
const [isEditing, setIsEditing] = React.useState(false);
|
const [isEditing, setIsEditing] = React.useState(false);
|
||||||
const [editBranchName, setEditBranchName] = React.useState(currentBranch || '');
|
const [editBranchName, setEditBranchName] = React.useState(currentBranch || '');
|
||||||
@@ -117,18 +119,17 @@ export const WorktreeBranchDisplay: React.FC<WorktreeBranchDisplayProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex w-full min-w-0 items-center gap-1.5 px-2 py-1 h-8">
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1 h-8">
|
<RiGitBranchLine className="size-4 text-primary shrink-0" />
|
||||||
<RiGitBranchLine className="size-4 text-primary" />
|
<div className="inline-flex min-w-0 max-w-full items-center gap-1">
|
||||||
<span className="max-w-[140px] truncate typography-ui-label font-normal text-foreground">
|
<span className="truncate typography-ui-label font-normal text-foreground">
|
||||||
{currentBranch || 'Detached HEAD'}
|
{currentBranch || 'Detached HEAD'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
{showEditButton && onRename && currentBranch && (
|
||||||
{onRename && currentBranch && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 w-8 p-0"
|
className="h-7 w-7 p-0 shrink-0"
|
||||||
onClick={handleStartEdit}
|
onClick={handleStartEdit}
|
||||||
title="Rename branch"
|
title="Rename branch"
|
||||||
>
|
>
|
||||||
@@ -136,5 +137,6 @@ export const WorktreeBranchDisplay: React.FC<WorktreeBranchDisplayProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -549,6 +549,25 @@ html:not(.dark) .chat-scroll {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Git right-sidebar header/actions collapse to icon-only when narrow. */
|
||||||
|
@container git-header (max-width: 26rem) {
|
||||||
|
.git-header-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated tabs: collapse labels based on local container width. */
|
||||||
|
@container animated-tabs (max-width: 23rem) {
|
||||||
|
.animated-tabs__label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-tabs__button {
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Text font: IBM Plex Sans */
|
/* Text font: IBM Plex Sans */
|
||||||
.streamdown-content {
|
.streamdown-content {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
|||||||
@@ -594,13 +594,30 @@ export type GitHubCheckRun = {
|
|||||||
url?: string;
|
url?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
conclusion?: string | null;
|
conclusion?: string | null;
|
||||||
steps?: Array<{ name: string; status?: string; conclusion?: string | null; number?: number }>;
|
steps?: Array<{
|
||||||
|
name: string;
|
||||||
|
status?: string;
|
||||||
|
conclusion?: string | null;
|
||||||
|
number?: number;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
annotations?: Array<{
|
||||||
|
path?: string;
|
||||||
|
startLine?: number;
|
||||||
|
endLine?: number;
|
||||||
|
level?: string;
|
||||||
|
message: string;
|
||||||
|
title?: string;
|
||||||
|
rawDetails?: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GitHubPullRequest = {
|
export type GitHubPullRequest = {
|
||||||
number: number;
|
number: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
body?: string;
|
||||||
url: string;
|
url: string;
|
||||||
state: 'open' | 'closed' | 'merged';
|
state: 'open' | 'closed' | 'merged';
|
||||||
draft: boolean;
|
draft: boolean;
|
||||||
@@ -686,6 +703,13 @@ export type GitHubPullRequestCreateInput = {
|
|||||||
draft?: boolean;
|
draft?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GitHubPullRequestUpdateInput = {
|
||||||
|
directory: string;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type GitHubPullRequestMergeInput = {
|
export type GitHubPullRequestMergeInput = {
|
||||||
directory: string;
|
directory: string;
|
||||||
number: number;
|
number: number;
|
||||||
@@ -794,6 +818,7 @@ export interface GitHubAPI {
|
|||||||
|
|
||||||
prStatus(directory: string, branch: string): Promise<GitHubPullRequestStatus>;
|
prStatus(directory: string, branch: string): Promise<GitHubPullRequestStatus>;
|
||||||
prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>;
|
prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>;
|
||||||
|
prUpdate(payload: GitHubPullRequestUpdateInput): Promise<GitHubPullRequest>;
|
||||||
prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult>;
|
prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult>;
|
||||||
prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult>;
|
prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult>;
|
||||||
|
|
||||||
|
|||||||
@@ -76,11 +76,11 @@ export function getTerminalOptions(
|
|||||||
fontFamily: augmentedFontFamily,
|
fontFamily: augmentedFontFamily,
|
||||||
fontSize,
|
fontSize,
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
cursorBlink: true,
|
cursorBlink: false,
|
||||||
cursorStyle: 'block' as const,
|
cursorStyle: 'bar' as const,
|
||||||
theme,
|
theme,
|
||||||
allowTransparency: false,
|
allowTransparency: false,
|
||||||
scrollback: 50_000,
|
scrollback: 10_000,
|
||||||
minimumContrastRatio: 1,
|
minimumContrastRatio: 1,
|
||||||
fastScrollModifier: 'shift' as const,
|
fastScrollModifier: 'shift' as const,
|
||||||
fastScrollSensitivity: 5,
|
fastScrollSensitivity: 5,
|
||||||
@@ -106,7 +106,8 @@ export function getGhosttyTerminalOptions(
|
|||||||
const augmentedFontFamily = `${fontFamily}, ${powerlineFallbacks}`;
|
const augmentedFontFamily = `${fontFamily}, ${powerlineFallbacks}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cursorBlink: true,
|
cursorBlink: false,
|
||||||
|
cursorStyle: 'bar' as const,
|
||||||
fontSize,
|
fontSize,
|
||||||
lineHeight: 1.15,
|
lineHeight: 1.15,
|
||||||
fontFamily: augmentedFontFamily,
|
fontFamily: augmentedFontFamily,
|
||||||
@@ -135,7 +136,7 @@ export function getGhosttyTerminalOptions(
|
|||||||
brightCyan: theme.brightCyan,
|
brightCyan: theme.brightCyan,
|
||||||
brightWhite: theme.brightWhite,
|
brightWhite: theme.brightWhite,
|
||||||
},
|
},
|
||||||
scrollback: 50_000,
|
scrollback: 10_000,
|
||||||
ghostty,
|
ghostty,
|
||||||
disableStdin,
|
disableStdin,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ interface UIStore {
|
|||||||
isSidebarOpen: boolean;
|
isSidebarOpen: boolean;
|
||||||
sidebarWidth: number;
|
sidebarWidth: number;
|
||||||
hasManuallyResizedLeftSidebar: boolean;
|
hasManuallyResizedLeftSidebar: boolean;
|
||||||
|
isRightSidebarOpen: boolean;
|
||||||
|
rightSidebarWidth: number;
|
||||||
|
hasManuallyResizedRightSidebar: boolean;
|
||||||
|
isBottomTerminalOpen: boolean;
|
||||||
|
bottomTerminalHeight: number;
|
||||||
|
hasManuallyResizedBottomTerminal: boolean;
|
||||||
isSessionSwitcherOpen: boolean;
|
isSessionSwitcherOpen: boolean;
|
||||||
activeMainTab: MainTab;
|
activeMainTab: MainTab;
|
||||||
mainTabGuard: MainTabGuard | null;
|
mainTabGuard: MainTabGuard | null;
|
||||||
@@ -81,6 +87,12 @@ interface UIStore {
|
|||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
setSidebarOpen: (open: boolean) => void;
|
setSidebarOpen: (open: boolean) => void;
|
||||||
setSidebarWidth: (width: number) => void;
|
setSidebarWidth: (width: number) => void;
|
||||||
|
toggleRightSidebar: () => void;
|
||||||
|
setRightSidebarOpen: (open: boolean) => void;
|
||||||
|
setRightSidebarWidth: (width: number) => void;
|
||||||
|
toggleBottomTerminal: () => void;
|
||||||
|
setBottomTerminalOpen: (open: boolean) => void;
|
||||||
|
setBottomTerminalHeight: (height: number) => void;
|
||||||
setSessionSwitcherOpen: (open: boolean) => void;
|
setSessionSwitcherOpen: (open: boolean) => void;
|
||||||
setActiveMainTab: (tab: MainTab) => void;
|
setActiveMainTab: (tab: MainTab) => void;
|
||||||
setMainTabGuard: (guard: MainTabGuard | null) => void;
|
setMainTabGuard: (guard: MainTabGuard | null) => void;
|
||||||
@@ -153,6 +165,12 @@ export const useUIStore = create<UIStore>()(
|
|||||||
isSidebarOpen: true,
|
isSidebarOpen: true,
|
||||||
sidebarWidth: 264,
|
sidebarWidth: 264,
|
||||||
hasManuallyResizedLeftSidebar: false,
|
hasManuallyResizedLeftSidebar: false,
|
||||||
|
isRightSidebarOpen: false,
|
||||||
|
rightSidebarWidth: 420,
|
||||||
|
hasManuallyResizedRightSidebar: false,
|
||||||
|
isBottomTerminalOpen: false,
|
||||||
|
bottomTerminalHeight: 300,
|
||||||
|
hasManuallyResizedBottomTerminal: false,
|
||||||
isSessionSwitcherOpen: false,
|
isSessionSwitcherOpen: false,
|
||||||
activeMainTab: 'chat',
|
activeMainTab: 'chat',
|
||||||
mainTabGuard: null,
|
mainTabGuard: null,
|
||||||
@@ -243,6 +261,76 @@ export const useUIStore = create<UIStore>()(
|
|||||||
set({ sidebarWidth: width, hasManuallyResizedLeftSidebar: true });
|
set({ sidebarWidth: width, hasManuallyResizedLeftSidebar: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
toggleRightSidebar: () => {
|
||||||
|
set((state) => {
|
||||||
|
const newOpen = !state.isRightSidebarOpen;
|
||||||
|
|
||||||
|
if (newOpen && typeof window !== 'undefined') {
|
||||||
|
const proportionalWidth = Math.floor(window.innerWidth * 0.28);
|
||||||
|
return {
|
||||||
|
isRightSidebarOpen: newOpen,
|
||||||
|
rightSidebarWidth: proportionalWidth,
|
||||||
|
hasManuallyResizedRightSidebar: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { isRightSidebarOpen: newOpen };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setRightSidebarOpen: (open) => {
|
||||||
|
set(() => {
|
||||||
|
if (open && typeof window !== 'undefined') {
|
||||||
|
const proportionalWidth = Math.floor(window.innerWidth * 0.28);
|
||||||
|
return {
|
||||||
|
isRightSidebarOpen: open,
|
||||||
|
rightSidebarWidth: proportionalWidth,
|
||||||
|
hasManuallyResizedRightSidebar: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { isRightSidebarOpen: open };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setRightSidebarWidth: (width) => {
|
||||||
|
set({ rightSidebarWidth: width, hasManuallyResizedRightSidebar: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleBottomTerminal: () => {
|
||||||
|
set((state) => {
|
||||||
|
const newOpen = !state.isBottomTerminalOpen;
|
||||||
|
|
||||||
|
if (newOpen && typeof window !== 'undefined') {
|
||||||
|
const proportionalHeight = Math.floor(window.innerHeight * 0.32);
|
||||||
|
return {
|
||||||
|
isBottomTerminalOpen: newOpen,
|
||||||
|
bottomTerminalHeight: proportionalHeight,
|
||||||
|
hasManuallyResizedBottomTerminal: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isBottomTerminalOpen: newOpen };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setBottomTerminalOpen: (open) => {
|
||||||
|
set(() => {
|
||||||
|
if (open && typeof window !== 'undefined') {
|
||||||
|
const proportionalHeight = Math.floor(window.innerHeight * 0.32);
|
||||||
|
return {
|
||||||
|
isBottomTerminalOpen: open,
|
||||||
|
bottomTerminalHeight: proportionalHeight,
|
||||||
|
hasManuallyResizedBottomTerminal: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isBottomTerminalOpen: open };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setBottomTerminalHeight: (height) => {
|
||||||
|
set({ bottomTerminalHeight: height, hasManuallyResizedBottomTerminal: true });
|
||||||
|
},
|
||||||
|
|
||||||
setSessionSwitcherOpen: (open) => {
|
setSessionSwitcherOpen: (open) => {
|
||||||
set({ isSessionSwitcherOpen: open });
|
set({ isSessionSwitcherOpen: open });
|
||||||
},
|
},
|
||||||
@@ -623,6 +711,14 @@ export const useUIStore = create<UIStore>()(
|
|||||||
updates.sidebarWidth = Math.floor(window.innerWidth * 0.2);
|
updates.sidebarWidth = Math.floor(window.innerWidth * 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.isRightSidebarOpen && !state.hasManuallyResizedRightSidebar) {
|
||||||
|
updates.rightSidebarWidth = Math.floor(window.innerWidth * 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isBottomTerminalOpen && !state.hasManuallyResizedBottomTerminal) {
|
||||||
|
updates.bottomTerminalHeight = Math.floor(window.innerHeight * 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
return updates;
|
return updates;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -702,6 +798,10 @@ export const useUIStore = create<UIStore>()(
|
|||||||
theme: state.theme,
|
theme: state.theme,
|
||||||
isSidebarOpen: state.isSidebarOpen,
|
isSidebarOpen: state.isSidebarOpen,
|
||||||
sidebarWidth: state.sidebarWidth,
|
sidebarWidth: state.sidebarWidth,
|
||||||
|
isRightSidebarOpen: state.isRightSidebarOpen,
|
||||||
|
rightSidebarWidth: state.rightSidebarWidth,
|
||||||
|
isBottomTerminalOpen: state.isBottomTerminalOpen,
|
||||||
|
bottomTerminalHeight: state.bottomTerminalHeight,
|
||||||
isSessionSwitcherOpen: state.isSessionSwitcherOpen,
|
isSessionSwitcherOpen: state.isSessionSwitcherOpen,
|
||||||
activeMainTab: state.activeMainTab,
|
activeMainTab: state.activeMainTab,
|
||||||
sidebarSection: state.sidebarSection,
|
sidebarSection: state.sidebarSection,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
markPullRequestReady,
|
markPullRequestReady,
|
||||||
mergePullRequest,
|
mergePullRequest,
|
||||||
|
updatePullRequest,
|
||||||
} from './githubPr';
|
} from './githubPr';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -1393,6 +1394,36 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'api:github/pr:update': {
|
||||||
|
const context = ctx?.context;
|
||||||
|
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||||
|
const stored = await readGitHubAuth(context);
|
||||||
|
if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' };
|
||||||
|
const directory = readStringField(payload, 'directory');
|
||||||
|
const number = readNumberField(payload, 'number') ?? 0;
|
||||||
|
const title = readStringField(payload, 'title');
|
||||||
|
const body = readStringField(payload, 'body');
|
||||||
|
if (!directory || !number || !title) {
|
||||||
|
return { id, type, success: false, error: 'directory, number, title are required' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pr = await updatePullRequest(stored.accessToken, directory, {
|
||||||
|
directory,
|
||||||
|
number,
|
||||||
|
title,
|
||||||
|
...(typeof body === 'string' ? { body } : {}),
|
||||||
|
});
|
||||||
|
return { id, type, success: true, data: pr };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (status === 401 || message === 'unauthorized') {
|
||||||
|
await clearGitHubAuth(context);
|
||||||
|
}
|
||||||
|
return { id, type, success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case 'api:github/pr:merge': {
|
case 'api:github/pr:merge': {
|
||||||
const context = ctx?.context;
|
const context = ctx?.context;
|
||||||
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type GitHubChecksSummary = {
|
|||||||
type GitHubPullRequest = {
|
type GitHubPullRequest = {
|
||||||
number: number;
|
number: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
body?: string;
|
||||||
url: string;
|
url: string;
|
||||||
state: 'open' | 'closed' | 'merged';
|
state: 'open' | 'closed' | 'merged';
|
||||||
draft: boolean;
|
draft: boolean;
|
||||||
@@ -48,6 +49,13 @@ type GitHubPullRequestCreateInput = {
|
|||||||
draft?: boolean;
|
draft?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GitHubPullRequestUpdateInput = {
|
||||||
|
directory: string;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type GitHubPullRequestMergeInput = {
|
type GitHubPullRequestMergeInput = {
|
||||||
directory: string;
|
directory: string;
|
||||||
number: number;
|
number: number;
|
||||||
@@ -208,6 +216,7 @@ export const getPullRequestStatus = async (
|
|||||||
const pr: GitHubPullRequest = {
|
const pr: GitHubPullRequest = {
|
||||||
number: typeof prJson.number === 'number' ? prJson.number : 0,
|
number: typeof prJson.number === 'number' ? prJson.number : 0,
|
||||||
title: readString(prJson.title) || '',
|
title: readString(prJson.title) || '',
|
||||||
|
body: readString(prJson.body) || '',
|
||||||
url: readString(prJson.html_url) || '',
|
url: readString(prJson.html_url) || '',
|
||||||
state,
|
state,
|
||||||
draft: Boolean(prJson.draft),
|
draft: Boolean(prJson.draft),
|
||||||
@@ -326,6 +335,7 @@ export const createPullRequest = async (
|
|||||||
return {
|
return {
|
||||||
number: typeof json.number === 'number' ? json.number : 0,
|
number: typeof json.number === 'number' ? json.number : 0,
|
||||||
title: readString(json.title) || '',
|
title: readString(json.title) || '',
|
||||||
|
body: readString(json.body) || '',
|
||||||
url: readString(json.html_url) || '',
|
url: readString(json.html_url) || '',
|
||||||
state: readString(json.state) === 'closed' ? 'closed' : 'open',
|
state: readString(json.state) === 'closed' ? 'closed' : 'open',
|
||||||
draft: Boolean(json.draft),
|
draft: Boolean(json.draft),
|
||||||
@@ -337,6 +347,62 @@ export const createPullRequest = async (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updatePullRequest = async (
|
||||||
|
accessToken: string,
|
||||||
|
directory: string,
|
||||||
|
payload: GitHubPullRequestUpdateInput,
|
||||||
|
): Promise<GitHubPullRequest> => {
|
||||||
|
const repo = await resolveRepoFromDirectory(directory);
|
||||||
|
if (!repo) {
|
||||||
|
throw new Error('Unable to resolve GitHub repo from git remote');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${payload.number}`, accessToken, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: payload.title,
|
||||||
|
...(typeof payload.body === 'string' ? { body: payload.body } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 403) {
|
||||||
|
throw new Error('Not authorized to edit this PR');
|
||||||
|
}
|
||||||
|
if (resp.status === 401) {
|
||||||
|
const error = new Error('unauthorized');
|
||||||
|
(error as unknown as { status?: number }).status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await jsonOrNull<JsonRecord>(resp);
|
||||||
|
if (!resp.ok || !json) {
|
||||||
|
const message = readString(json?.message);
|
||||||
|
const firstError = Array.isArray(json?.errors) && json.errors.length > 0
|
||||||
|
? readString((json.errors[0] as JsonRecord)?.message || (json.errors[0] as JsonRecord)?.code)
|
||||||
|
: '';
|
||||||
|
const details = [message, firstError].filter(Boolean).join(' · ');
|
||||||
|
throw new Error(details || 'Failed to update PR');
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = Boolean(json.merged || json.merged_at);
|
||||||
|
const state = merged ? 'merged' : (readString(json.state) === 'closed' ? 'closed' : 'open');
|
||||||
|
|
||||||
|
return {
|
||||||
|
number: typeof json.number === 'number' ? json.number : payload.number,
|
||||||
|
title: readString(json.title) || payload.title,
|
||||||
|
body: readString(json.body) || '',
|
||||||
|
url: readString(json.html_url) || '',
|
||||||
|
state,
|
||||||
|
draft: Boolean(json.draft),
|
||||||
|
base: readString((json.base as JsonRecord | undefined)?.ref) || '',
|
||||||
|
head: readString((json.head as JsonRecord | undefined)?.ref) || '',
|
||||||
|
headSha: readString((json.head as JsonRecord | undefined)?.sha) || undefined,
|
||||||
|
mergeable: typeof json.mergeable === 'boolean' ? json.mergeable : null,
|
||||||
|
mergeableState: readString(json.mergeable_state) || undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const mergePullRequest = async (
|
export const mergePullRequest = async (
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
directory: string,
|
directory: string,
|
||||||
|
|||||||
@@ -37,8 +37,24 @@ type GitHubCheckRun = {
|
|||||||
url?: string;
|
url?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
conclusion?: string | null;
|
conclusion?: string | null;
|
||||||
steps?: Array<{ name: string; status?: string; conclusion?: string | null; number?: number }>;
|
steps?: Array<{
|
||||||
|
name: string;
|
||||||
|
status?: string;
|
||||||
|
conclusion?: string | null;
|
||||||
|
number?: number;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
annotations?: Array<{
|
||||||
|
path?: string;
|
||||||
|
startLine?: number;
|
||||||
|
endLine?: number;
|
||||||
|
level?: string;
|
||||||
|
message: string;
|
||||||
|
title?: string;
|
||||||
|
rawDetails?: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GitHubPullRequestHeadRepo = { owner: string; repo: string; url: string; cloneUrl?: string };
|
type GitHubPullRequestHeadRepo = { owner: string; repo: string; url: string; cloneUrl?: string };
|
||||||
@@ -456,7 +472,77 @@ export const getPullRequestContext = async (
|
|||||||
jobsByRunId.set(runId, jobs.filter((j) => j && typeof j === 'object') as JsonRecord[]);
|
jobsByRunId.set(runId, jobs.filter((j) => j && typeof j === 'object') as JsonRecord[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const annotationsByRunId = new Map<number, Array<{
|
||||||
|
path?: string;
|
||||||
|
startLine?: number;
|
||||||
|
endLine?: number;
|
||||||
|
level?: string;
|
||||||
|
message: string;
|
||||||
|
title?: string;
|
||||||
|
rawDetails?: string;
|
||||||
|
}>>();
|
||||||
|
|
||||||
for (const run of checkRuns) {
|
for (const run of checkRuns) {
|
||||||
|
const runId = typeof run.id === 'number' ? run.id : 0;
|
||||||
|
const conclusion = (run.conclusion || '').toLowerCase();
|
||||||
|
const shouldLoadAnnotations = Boolean(
|
||||||
|
runId > 0
|
||||||
|
&& conclusion
|
||||||
|
&& !['success', 'neutral', 'skipped'].includes(conclusion),
|
||||||
|
);
|
||||||
|
if (!shouldLoadAnnotations) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const annotations: Array<{
|
||||||
|
path?: string;
|
||||||
|
startLine?: number;
|
||||||
|
endLine?: number;
|
||||||
|
level?: string;
|
||||||
|
message: string;
|
||||||
|
title?: string;
|
||||||
|
rawDetails?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let page = 1; page <= 3; page += 1) {
|
||||||
|
const annotationsResp = await githubFetch(
|
||||||
|
`${API_BASE}/repos/${repo.owner}/${repo.repo}/check-runs/${runId}/annotations?per_page=50&page=${page}`,
|
||||||
|
accessToken,
|
||||||
|
);
|
||||||
|
if (annotationsResp.status === 401) {
|
||||||
|
return { connected: false };
|
||||||
|
}
|
||||||
|
const annotationsJson = await jsonOrNull<unknown[]>(annotationsResp);
|
||||||
|
const chunk = Array.isArray(annotationsJson) ? annotationsJson : [];
|
||||||
|
chunk.forEach((entry) => {
|
||||||
|
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||||
|
const message = readString(rec?.message);
|
||||||
|
if (!message) return;
|
||||||
|
annotations.push({
|
||||||
|
path: readString(rec?.path) || undefined,
|
||||||
|
startLine: typeof rec?.start_line === 'number' ? rec.start_line : undefined,
|
||||||
|
endLine: typeof rec?.end_line === 'number' ? rec.end_line : undefined,
|
||||||
|
level: readString(rec?.annotation_level) || undefined,
|
||||||
|
message,
|
||||||
|
title: readString(rec?.title) || undefined,
|
||||||
|
rawDetails: readString(rec?.raw_details) || undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (chunk.length < 50) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (annotations.length > 0) {
|
||||||
|
annotationsByRunId.set(runId, annotations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const run of checkRuns) {
|
||||||
|
if (run.id && annotationsByRunId.has(run.id)) {
|
||||||
|
run.annotations = annotationsByRunId.get(run.id);
|
||||||
|
}
|
||||||
|
|
||||||
const ids = parseIds(run.detailsUrl);
|
const ids = parseIds(run.detailsUrl);
|
||||||
if (!ids.runId) continue;
|
if (!ids.runId) continue;
|
||||||
const jobs = jobsByRunId.get(ids.runId) ?? [];
|
const jobs = jobsByRunId.get(ids.runId) ?? [];
|
||||||
@@ -480,9 +566,18 @@ export const getPullRequestContext = async (
|
|||||||
? (rec?.conclusion as string | null)
|
? (rec?.conclusion as string | null)
|
||||||
: undefined,
|
: undefined,
|
||||||
number: typeof rec?.number === 'number' ? rec.number : undefined,
|
number: typeof rec?.number === 'number' ? rec.number : undefined,
|
||||||
|
startedAt: readString(rec?.started_at) || undefined,
|
||||||
|
completedAt: readString(rec?.completed_at) || undefined,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean) as Array<{ name: string; status?: string; conclusion?: string | null; number?: number }>;
|
.filter(Boolean) as Array<{
|
||||||
|
name: string;
|
||||||
|
status?: string;
|
||||||
|
conclusion?: string | null;
|
||||||
|
number?: number;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
run.job = {
|
run.job = {
|
||||||
runId: ids.runId,
|
runId: ids.runId,
|
||||||
@@ -494,6 +589,7 @@ export const getPullRequestContext = async (
|
|||||||
: undefined,
|
: undefined,
|
||||||
steps: steps.length > 0 ? steps : undefined,
|
steps: steps.length > 0 ? steps : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
GitHubPullRequestMergeResult,
|
GitHubPullRequestMergeResult,
|
||||||
GitHubPullRequestReadyInput,
|
GitHubPullRequestReadyInput,
|
||||||
GitHubPullRequestReadyResult,
|
GitHubPullRequestReadyResult,
|
||||||
|
GitHubPullRequestUpdateInput,
|
||||||
GitHubPullRequestStatus,
|
GitHubPullRequestStatus,
|
||||||
GitHubDeviceFlowComplete,
|
GitHubDeviceFlowComplete,
|
||||||
GitHubDeviceFlowStart,
|
GitHubDeviceFlowStart,
|
||||||
@@ -34,6 +35,8 @@ export const createVSCodeGitHubAPI = (): GitHubAPI => ({
|
|||||||
sendBridgeMessage<GitHubPullRequestStatus>('api:github/pr:status', { directory, branch }),
|
sendBridgeMessage<GitHubPullRequestStatus>('api:github/pr:status', { directory, branch }),
|
||||||
prCreate: async (payload: GitHubPullRequestCreateInput) =>
|
prCreate: async (payload: GitHubPullRequestCreateInput) =>
|
||||||
sendBridgeMessage<GitHubPullRequest>('api:github/pr:create', payload),
|
sendBridgeMessage<GitHubPullRequest>('api:github/pr:create', payload),
|
||||||
|
prUpdate: async (payload: GitHubPullRequestUpdateInput) =>
|
||||||
|
sendBridgeMessage<GitHubPullRequest>('api:github/pr:update', payload),
|
||||||
prMerge: async (payload: GitHubPullRequestMergeInput) =>
|
prMerge: async (payload: GitHubPullRequestMergeInput) =>
|
||||||
sendBridgeMessage<GitHubPullRequestMergeResult>('api:github/pr:merge', payload),
|
sendBridgeMessage<GitHubPullRequestMergeResult>('api:github/pr:merge', payload),
|
||||||
prReady: async (payload: GitHubPullRequestReadyInput) =>
|
prReady: async (payload: GitHubPullRequestReadyInput) =>
|
||||||
|
|||||||
@@ -6215,6 +6215,7 @@ async function main(options = {}) {
|
|||||||
pr: {
|
pr: {
|
||||||
number: prData.number,
|
number: prData.number,
|
||||||
title: prData.title,
|
title: prData.title,
|
||||||
|
body: prData.body || '',
|
||||||
url: prData.html_url,
|
url: prData.html_url,
|
||||||
state: mergedState,
|
state: mergedState,
|
||||||
draft: Boolean(prData.draft),
|
draft: Boolean(prData.draft),
|
||||||
@@ -6280,6 +6281,7 @@ async function main(options = {}) {
|
|||||||
return res.json({
|
return res.json({
|
||||||
number: pr.number,
|
number: pr.number,
|
||||||
title: pr.title,
|
title: pr.title,
|
||||||
|
body: pr.body || '',
|
||||||
url: pr.html_url,
|
url: pr.html_url,
|
||||||
state: pr.state === 'closed' ? 'closed' : 'open',
|
state: pr.state === 'closed' ? 'closed' : 'open',
|
||||||
draft: Boolean(pr.draft),
|
draft: Boolean(pr.draft),
|
||||||
@@ -6295,6 +6297,82 @@ async function main(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/github/pr/update', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
||||||
|
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||||
|
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
|
||||||
|
const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
|
||||||
|
if (!directory || !number || !title) {
|
||||||
|
return res.status(400).json({ error: 'directory, number, title are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||||
|
const octokit = getOctokitOrNull();
|
||||||
|
if (!octokit) {
|
||||||
|
return res.status(401).json({ error: 'GitHub not connected' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
|
||||||
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
||||||
|
if (!repo) {
|
||||||
|
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated;
|
||||||
|
try {
|
||||||
|
updated = await octokit.rest.pulls.update({
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
pull_number: number,
|
||||||
|
title,
|
||||||
|
...(typeof body === 'string' ? { body } : {}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status === 401) {
|
||||||
|
return res.status(401).json({ error: 'GitHub not connected' });
|
||||||
|
}
|
||||||
|
if (error?.status === 403) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized to edit this PR' });
|
||||||
|
}
|
||||||
|
if (error?.status === 404) {
|
||||||
|
return res.status(404).json({ error: 'PR not found in this repository' });
|
||||||
|
}
|
||||||
|
if (error?.status === 422) {
|
||||||
|
const apiMessage = error?.response?.data?.message;
|
||||||
|
const firstError = Array.isArray(error?.response?.data?.errors) && error.response.data.errors.length > 0
|
||||||
|
? (error.response.data.errors[0]?.message || error.response.data.errors[0]?.code)
|
||||||
|
: null;
|
||||||
|
const message = [apiMessage, firstError].filter(Boolean).join(' · ') || 'Invalid PR update payload';
|
||||||
|
return res.status(422).json({ error: message });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pr = updated?.data;
|
||||||
|
if (!pr) {
|
||||||
|
return res.status(500).json({ error: 'Failed to update PR' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
number: pr.number,
|
||||||
|
title: pr.title,
|
||||||
|
body: pr.body || '',
|
||||||
|
url: pr.html_url,
|
||||||
|
state: pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open'),
|
||||||
|
draft: Boolean(pr.draft),
|
||||||
|
base: pr.base?.ref,
|
||||||
|
head: pr.head?.ref,
|
||||||
|
headSha: pr.head?.sha,
|
||||||
|
mergeable: pr.mergeable,
|
||||||
|
mergeableState: pr.mergeable_state,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update GitHub PR:', error);
|
||||||
|
return res.status(500).json({ error: error.message || 'Failed to update GitHub PR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/github/pr/merge', async (req, res) => {
|
app.post('/api/github/pr/merge', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
||||||
@@ -6739,6 +6817,7 @@ async function main(options = {}) {
|
|||||||
const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : [];
|
const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : [];
|
||||||
if (checkRuns.length > 0) {
|
if (checkRuns.length > 0) {
|
||||||
const parsedJobs = new Map();
|
const parsedJobs = new Map();
|
||||||
|
const parsedAnnotations = new Map();
|
||||||
if (includeCheckDetails) {
|
if (includeCheckDetails) {
|
||||||
// Prefetch actions jobs per runId.
|
// Prefetch actions jobs per runId.
|
||||||
const runIds = new Set();
|
const runIds = new Set();
|
||||||
@@ -6774,6 +6853,47 @@ async function main(options = {}) {
|
|||||||
parsedJobs.set(runId, []);
|
parsedJobs.set(runId, []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const run of checkRuns) {
|
||||||
|
const runConclusion = typeof run?.conclusion === 'string' ? run.conclusion.toLowerCase() : '';
|
||||||
|
const shouldLoadAnnotations = Boolean(
|
||||||
|
run?.id
|
||||||
|
&& runConclusion
|
||||||
|
&& !['success', 'neutral', 'skipped'].includes(runConclusion)
|
||||||
|
);
|
||||||
|
if (!shouldLoadAnnotations) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkRunId = Number(run.id);
|
||||||
|
if (!Number.isFinite(checkRunId) || checkRunId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const annotations = [];
|
||||||
|
for (let page = 1; page <= 3; page += 1) {
|
||||||
|
try {
|
||||||
|
const annotationsResp = await octokit.rest.checks.listAnnotations({
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
check_run_id: checkRunId,
|
||||||
|
per_page: 50,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
const chunk = Array.isArray(annotationsResp?.data) ? annotationsResp.data : [];
|
||||||
|
annotations.push(...chunk);
|
||||||
|
if (chunk.length < 50) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (annotations.length > 0) {
|
||||||
|
parsedAnnotations.set(checkRunId, annotations);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checkRunsOut = checkRuns.map((run) => {
|
checkRunsOut = checkRuns.map((run) => {
|
||||||
@@ -6802,6 +6922,8 @@ async function main(options = {}) {
|
|||||||
status: s.status,
|
status: s.status,
|
||||||
conclusion: s.conclusion,
|
conclusion: s.conclusion,
|
||||||
number: s.number,
|
number: s.number,
|
||||||
|
startedAt: s.started_at || undefined,
|
||||||
|
completedAt: s.completed_at || undefined,
|
||||||
}))
|
}))
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
@@ -6831,6 +6953,19 @@ async function main(options = {}) {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
...(job ? { job } : {}),
|
...(job ? { job } : {}),
|
||||||
|
...(run.id && parsedAnnotations.has(run.id)
|
||||||
|
? {
|
||||||
|
annotations: parsedAnnotations.get(run.id).map((a) => ({
|
||||||
|
path: a.path || undefined,
|
||||||
|
startLine: typeof a.start_line === 'number' ? a.start_line : undefined,
|
||||||
|
endLine: typeof a.end_line === 'number' ? a.end_line : undefined,
|
||||||
|
level: a.annotation_level || undefined,
|
||||||
|
message: a.message || '',
|
||||||
|
title: a.title || undefined,
|
||||||
|
rawDetails: a.raw_details || undefined,
|
||||||
|
})).filter((a) => a.message),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const counts = { success: 0, failure: 0, pending: 0 };
|
const counts = { success: 0, failure: 0, pending: 0 };
|
||||||
@@ -7440,12 +7575,17 @@ async function main(options = {}) {
|
|||||||
|
|
||||||
const diffSummaries = diffs.map(({ path, diff }) => `FILE: ${path}\n${diff}`).join('\n\n');
|
const diffSummaries = diffs.map(({ path, diff }) => `FILE: ${path}\n${diff}`).join('\n\n');
|
||||||
|
|
||||||
let prompt = `You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:
|
let prompt = `You are drafting a GitHub Pull Request title + description for a squash-merge workflow.
|
||||||
- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")
|
Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:
|
||||||
- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes
|
- Title format: conventional, outcome-first, <= 90 chars, no trailing punctuation.
|
||||||
- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names
|
- Use: <type>(<scope>): <summary>. Types: feat, fix, refactor, perf, docs, test, chore.
|
||||||
- Testing: bullet list ("- Not tested" allowed)
|
- Pick the most important user-facing outcome first; include a second major outcome only when needed.
|
||||||
- Notes: bullet list; include breaking/rollout notes only when relevant
|
- Body: GitHub-flavored markdown with sections in this exact order: ## Summary, ## Why, ## Testing.
|
||||||
|
- Summary: 3-6 bullets, concrete product/workflow impact, no vague filler, no internal helper names.
|
||||||
|
- Why: 1-3 bullets explaining motivation/tradeoff (what problem this solves for users/devs).
|
||||||
|
- Testing: checkbox list using "- [ ]"; include realistic manual/automated checks inferred from the diff.
|
||||||
|
- If tests were not run, include "- [ ] Not run locally" as first testing item.
|
||||||
|
- Keep language crisp and specific; avoid generic boilerplate.
|
||||||
|
|
||||||
Context:
|
Context:
|
||||||
- base branch: ${base}
|
- base branch: ${base}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
GitHubPullRequestMergeResult,
|
GitHubPullRequestMergeResult,
|
||||||
GitHubPullRequestReadyInput,
|
GitHubPullRequestReadyInput,
|
||||||
GitHubPullRequestReadyResult,
|
GitHubPullRequestReadyResult,
|
||||||
|
GitHubPullRequestUpdateInput,
|
||||||
GitHubPullRequestStatus,
|
GitHubPullRequestStatus,
|
||||||
GitHubDeviceFlowComplete,
|
GitHubDeviceFlowComplete,
|
||||||
GitHubDeviceFlowStart,
|
GitHubDeviceFlowStart,
|
||||||
@@ -114,6 +115,19 @@ export const createWebGitHubAPI = (): GitHubAPI => ({
|
|||||||
return body;
|
return body;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async prUpdate(payload: GitHubPullRequestUpdateInput): Promise<GitHubPullRequest> {
|
||||||
|
const response = await fetch('/api/github/pr/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const body = await jsonOrNull<GitHubPullRequest & { error?: string }>(response);
|
||||||
|
if (!response.ok || !body) {
|
||||||
|
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to update PR');
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
|
||||||
async prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult> {
|
async prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult> {
|
||||||
const response = await fetch('/api/github/pr/merge', {
|
const response = await fetch('/api/github/pr/merge', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user