feat: Add branch name under worktree in session list (if different than worktree name) (#352)
* feat: show branch info in session sidebar and enrich worktrees * feat: refresh sidebar branch display when switching branches in git menu * fix: ensure branch name displays on mobile by fixing flex alignment * style: make branch name font smaller, especially on mobile
This commit is contained in:
@@ -68,6 +68,7 @@ import { checkIsGitRepository } from '@/lib/gitApi';
|
|||||||
import { getSafeStorage } from '@/stores/utils/safeStorage';
|
import { getSafeStorage } from '@/stores/utils/safeStorage';
|
||||||
import { createWorktreeOnly, createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
import { createWorktreeOnly, createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||||
|
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 { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
@@ -123,6 +124,19 @@ const normalizePath = (value?: string | null) => {
|
|||||||
return normalized.length === 0 ? '/' : normalized;
|
return normalized.length === 0 ? '/' : normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeForBranchComparison = (value: string): string => {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^opencode[/-]?/i, '')
|
||||||
|
.replace(/[-_]/g, '')
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBranchDifferentFromLabel = (branch: string | null, label: string): boolean => {
|
||||||
|
if (!branch) return false;
|
||||||
|
return normalizeForBranchComparison(branch) !== normalizeForBranchComparison(label);
|
||||||
|
};
|
||||||
|
|
||||||
const toFiniteNumber = (value: unknown): number | undefined => {
|
const toFiniteNumber = (value: unknown): number | undefined => {
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
return value;
|
return value;
|
||||||
@@ -217,6 +231,7 @@ type SessionNode = {
|
|||||||
type SessionGroup = {
|
type SessionGroup = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
branch: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
worktree: WorktreeMetadata | null;
|
worktree: WorktreeMetadata | null;
|
||||||
@@ -671,6 +686,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
|
|
||||||
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
|
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
|
||||||
|
|
||||||
|
const gitDirectories = useGitStore((state) => state.directories);
|
||||||
|
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
const sessions = useSessionStore((state) => state.sessions);
|
||||||
const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory);
|
const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory);
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||||
@@ -1241,6 +1258,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
label: (projectIsRepo && projectRootBranch && projectRootBranch !== 'HEAD')
|
label: (projectIsRepo && projectRootBranch && projectRootBranch !== 'HEAD')
|
||||||
? `project root: ${projectRootBranch}`
|
? `project root: ${projectRootBranch}`
|
||||||
: 'project root',
|
: 'project root',
|
||||||
|
branch: projectRootBranch ?? null,
|
||||||
description: normalizedProjectRoot ? formatPathForDisplay(normalizedProjectRoot, homeDirectory) : null,
|
description: normalizedProjectRoot ? formatPathForDisplay(normalizedProjectRoot, homeDirectory) : null,
|
||||||
isMain: true,
|
isMain: true,
|
||||||
worktree: null,
|
worktree: null,
|
||||||
@@ -1256,10 +1274,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
|
|
||||||
sortedWorktrees.forEach((meta) => {
|
sortedWorktrees.forEach((meta) => {
|
||||||
const directory = normalizePath(meta.path) ?? meta.path;
|
const directory = normalizePath(meta.path) ?? meta.path;
|
||||||
const label = meta.branch || meta.name || meta.label || formatDirectoryName(directory, homeDirectory) || directory;
|
const label = meta.label || meta.name || formatDirectoryName(directory, homeDirectory) || directory;
|
||||||
groups.push({
|
groups.push({
|
||||||
id: `worktree:${directory}`,
|
id: `worktree:${directory}`,
|
||||||
label,
|
label,
|
||||||
|
branch: meta.branch || null,
|
||||||
description: formatPathForDisplay(directory, homeDirectory),
|
description: formatPathForDisplay(directory, homeDirectory),
|
||||||
isMain: false,
|
isMain: false,
|
||||||
worktree: meta,
|
worktree: meta,
|
||||||
@@ -1277,6 +1296,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
groups.push({
|
groups.push({
|
||||||
id: `worktree:orphan:${directory}`,
|
id: `worktree:orphan:${directory}`,
|
||||||
label: formatDirectoryName(directory, homeDirectory) || directory,
|
label: formatDirectoryName(directory, homeDirectory) || directory,
|
||||||
|
branch: null,
|
||||||
description: formatPathForDisplay(directory, homeDirectory),
|
description: formatPathForDisplay(directory, homeDirectory),
|
||||||
isMain: false,
|
isMain: false,
|
||||||
worktree: null,
|
worktree: null,
|
||||||
@@ -1364,6 +1384,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
}>;
|
}>;
|
||||||
}, [projects]);
|
}, [projects]);
|
||||||
|
|
||||||
|
// Compute a dependency that changes when any project's git branch changes
|
||||||
|
const projectGitBranchesKey = React.useMemo(() => {
|
||||||
|
return normalizedProjects
|
||||||
|
.map((project) => {
|
||||||
|
const dirState = gitDirectories.get(project.normalizedPath);
|
||||||
|
return `${project.id}:${dirState?.status?.current ?? ''}`;
|
||||||
|
})
|
||||||
|
.join('|');
|
||||||
|
}, [normalizedProjects, gitDirectories]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
@@ -1390,7 +1420,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [normalizedProjects]);
|
}, [normalizedProjects, projectGitBranchesKey]);
|
||||||
|
|
||||||
const getSessionsForProject = React.useCallback(
|
const getSessionsForProject = React.useCallback(
|
||||||
(project: { normalizedPath: string }) => {
|
(project: { normalizedPath: string }) => {
|
||||||
@@ -2263,7 +2293,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="oc-group">
|
<div className="oc-group">
|
||||||
<div
|
<div
|
||||||
className="group/gh flex items-center justify-between gap-2 py-1 min-h-8 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={() => {
|
onMouseEnter={() => {
|
||||||
if (!group.isMain) {
|
if (!group.isMain) {
|
||||||
void ensureWorktreePrLoaded(groupKey, group.directory, group.label, group.worktree);
|
void ensureWorktreePrLoaded(groupKey, group.directory, group.label, group.worktree);
|
||||||
@@ -2304,11 +2334,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
}}
|
}}
|
||||||
aria-label={isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`}
|
aria-label={isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex items-center gap-1.5 px-0">
|
<div className="min-w-0 flex items-start gap-1.5 px-0 pt-0.5">
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
<RiArrowRightSLine 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" />
|
<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 ? (
|
!group.isMain && groupPr?.url ? (
|
||||||
@@ -2322,7 +2352,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
void openExternal(groupPr.url);
|
void openExternal(groupPr.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-interactive-hover/50"
|
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-interactive-hover/50 mt-1"
|
||||||
style={{ color: prColorVar }}
|
style={{ color: prColorVar }}
|
||||||
aria-label={getPrTooltipLabel(groupPrStatus)}
|
aria-label={getPrTooltipLabel(groupPrStatus)}
|
||||||
>
|
>
|
||||||
@@ -2334,12 +2364,19 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
<RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground mt-1" />
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
<p className={cn('text-[15px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
|
<div className="min-w-0 flex flex-col">
|
||||||
{group.label}
|
<p className={cn('text-[15px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
|
||||||
</p>
|
{group.label}
|
||||||
|
</p>
|
||||||
|
{!group.isMain && isBranchDifferentFromLabel(group.branch, group.label) ? (
|
||||||
|
<span className="text-[10px] sm:text-[11px] text-muted-foreground/80 truncate leading-tight">
|
||||||
|
{group.branch}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{group.directory ? (
|
{group.directory ? (
|
||||||
<div className="flex items-center gap-1 px-0.5">
|
<div className="flex items-center gap-1 px-0.5">
|
||||||
|
|||||||
@@ -1891,7 +1891,10 @@ export const FilesView: React.FC = () => {
|
|||||||
value={draftContent}
|
value={draftContent}
|
||||||
onChange={setDraftContent}
|
onChange={setDraftContent}
|
||||||
extensions={editorExtensions}
|
extensions={editorExtensions}
|
||||||
className="h-full"
|
className={cn(
|
||||||
|
"h-full",
|
||||||
|
isMobile && "[&_.cm-scroller]:pb-[var(--oc-keyboard-inset,0px)]"
|
||||||
|
)}
|
||||||
highlightLines={lineSelection
|
highlightLines={lineSelection
|
||||||
? {
|
? {
|
||||||
start: Math.min(lineSelection.start, lineSelection.end),
|
start: Math.min(lineSelection.start, lineSelection.end),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { substituteCommandVariables } from '@/lib/openchamberConfig';
|
import { substituteCommandVariables } from '@/lib/openchamberConfig';
|
||||||
import type { WorktreeMetadata } from '@/types/worktree';
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
import { deleteRemoteBranch } from '@/lib/gitApi';
|
import { deleteRemoteBranch, getGitStatus } from '@/lib/gitApi';
|
||||||
|
|
||||||
export type ProjectRef = { id: string; path: string };
|
export type ProjectRef = { id: string; path: string };
|
||||||
|
|
||||||
@@ -143,6 +143,21 @@ export async function listProjectWorktrees(project: ProjectRef): Promise<Worktre
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enrich worktrees with branch information from git status
|
||||||
|
await Promise.all(
|
||||||
|
results.map(async (worktree) => {
|
||||||
|
try {
|
||||||
|
const status = await getGitStatus(worktree.path);
|
||||||
|
if (status?.current) {
|
||||||
|
worktree.branch = status.current;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore - branch will remain empty
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return results.sort((a, b) => {
|
return results.sort((a, b) => {
|
||||||
const aLabel = (a.label || a.branch || a.path).toLowerCase();
|
const aLabel = (a.label || a.branch || a.path).toLowerCase();
|
||||||
const bLabel = (b.label || b.branch || b.path).toLowerCase();
|
const bLabel = (b.label || b.branch || b.path).toLowerCase();
|
||||||
|
|||||||
Reference in New Issue
Block a user