feat(vscode): support multi-root workspaces (#1493)
Add proper VS Code multi-root workspace support. New sessions now start in the workspace folder the user chooses instead of always using the first folder. The VS Code sidebar now shows one shared flat session list for the currently opened workspace folders, keeps that list synced when folders are added or removed, and excludes sessions from worktrees unless that worktree is opened as a workspace folder. Also keep the OpenCode server process independent from a specific workspace folder so changing the selected folder does not restart or interrupt existing sessions.
This commit is contained in:
committed by
GitHub
parent
49a1424e5f
commit
7b33805ea0
@@ -61,6 +61,7 @@ import { type SessionGroup, type SessionNode } from './sidebar/types';
|
||||
import {
|
||||
deriveActiveNowSessions,
|
||||
deriveLiveActiveNowSessions,
|
||||
getSessionUpdatedAtMs,
|
||||
} from './sidebar/activitySections';
|
||||
import { useActiveNowStore } from '@/stores/useActiveNowStore';
|
||||
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
||||
@@ -84,6 +85,8 @@ const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
|
||||
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
|
||||
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
|
||||
const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject';
|
||||
const VSCODE_RECENT_INITIAL_SESSION_COUNT = 20;
|
||||
const VSCODE_RECENT_SESSION_BATCH_SIZE = 20;
|
||||
// v2 key holds composite "${renderContext}:${active|archived}:${sessionId}"
|
||||
// entries so the same session in different render contexts (e.g. "Recent"
|
||||
// and a project's root) has independent expand state. v1 held bare session
|
||||
@@ -122,12 +125,16 @@ type PrIndicator = {
|
||||
const buildKnownSessionDirectories = (
|
||||
projects: Array<{ path: string }>,
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
|
||||
options?: { includeWorktrees?: boolean },
|
||||
): Set<string> => {
|
||||
const directories = new Set<string>();
|
||||
for (const project of projects) {
|
||||
const normalized = normalizePath(project.path)?.toLowerCase();
|
||||
if (normalized) directories.add(normalized);
|
||||
}
|
||||
if (options?.includeWorktrees === false) {
|
||||
return directories;
|
||||
}
|
||||
for (const worktrees of availableWorktreesByProject.values()) {
|
||||
for (const worktree of worktrees) {
|
||||
const normalized = normalizePath(worktree.path)?.toLowerCase();
|
||||
@@ -137,11 +144,15 @@ const buildKnownSessionDirectories = (
|
||||
return directories;
|
||||
};
|
||||
|
||||
const isKnownActiveSessionDirectory = (session: Session, knownDirectories: Set<string>): boolean => {
|
||||
const isKnownActiveSessionDirectory = (
|
||||
session: Session,
|
||||
knownDirectories: Set<string>,
|
||||
options?: { allowUnknownDirectory?: boolean; allowEmptyDirectorySet?: boolean },
|
||||
): boolean => {
|
||||
if (session.time?.archived) return true;
|
||||
const directory = normalizePath(resolveGlobalSessionDirectory(session))?.toLowerCase();
|
||||
if (!directory) return true;
|
||||
if (knownDirectories.size === 0) return true;
|
||||
if (!directory) return options?.allowUnknownDirectory ?? true;
|
||||
if (knownDirectories.size === 0) return options?.allowEmptyDirectorySet ?? true;
|
||||
return knownDirectories.has(directory);
|
||||
};
|
||||
|
||||
@@ -305,6 +316,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const sync = useSync();
|
||||
const liveSessions = useAllLiveSessions();
|
||||
const liveSessionStatuses = useAllSessionStatuses();
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
|
||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
||||
@@ -332,8 +344,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
})));
|
||||
|
||||
const knownSessionDirectories = React.useMemo(
|
||||
() => buildKnownSessionDirectories(projects, availableWorktreesByProject),
|
||||
[availableWorktreesByProject, projects],
|
||||
() => buildKnownSessionDirectories(projects, availableWorktreesByProject, { includeWorktrees: !isVSCode }),
|
||||
[availableWorktreesByProject, isVSCode, projects],
|
||||
);
|
||||
|
||||
const sessions = React.useMemo(() => {
|
||||
@@ -351,8 +363,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
merged.push(session);
|
||||
});
|
||||
|
||||
return merged.filter((session) => isKnownActiveSessionDirectory(session, knownSessionDirectories));
|
||||
}, [globalActiveSessions, knownSessionDirectories, liveSessions]);
|
||||
return merged.filter((session) => isKnownActiveSessionDirectory(session, knownSessionDirectories, {
|
||||
allowUnknownDirectory: !isVSCode,
|
||||
allowEmptyDirectorySet: !isVSCode,
|
||||
}));
|
||||
}, [globalActiveSessions, isVSCode, knownSessionDirectories, liveSessions]);
|
||||
|
||||
const syncSessionStructureSignature = React.useMemo(
|
||||
() => liveSessions
|
||||
@@ -452,7 +467,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
|
||||
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
|
||||
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
const { isTablet } = useDeviceInfo();
|
||||
const alwaysShowSidebarActions = mobileVariant || isTablet;
|
||||
|
||||
@@ -804,6 +818,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const directories = new Set<string>();
|
||||
normalizedProjects.forEach((project) => {
|
||||
if (project.normalizedPath) directories.add(project.normalizedPath);
|
||||
if (isVSCode) {
|
||||
return;
|
||||
}
|
||||
const worktrees = availableWorktreesByProject.get(project.normalizedPath) ?? [];
|
||||
worktrees.forEach((worktree) => {
|
||||
const directory = normalizePath(worktree.path);
|
||||
@@ -811,7 +828,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
});
|
||||
});
|
||||
return [...directories].sort();
|
||||
}, [availableWorktreesByProject, normalizedProjects]);
|
||||
}, [availableWorktreesByProject, isVSCode, normalizedProjects]);
|
||||
|
||||
const knownProjectSessionDirectoriesRef = React.useRef<Set<string> | null>(null);
|
||||
React.useEffect(() => {
|
||||
@@ -819,6 +836,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const previousDirectories = knownProjectSessionDirectoriesRef.current;
|
||||
knownProjectSessionDirectoriesRef.current = nextDirectories;
|
||||
if (!previousDirectories) {
|
||||
if (isVSCode && projectSessionDirectories.length > 0) {
|
||||
void refreshGlobalSessionsForDirectories(projectSessionDirectories, syncSessionsSnapshotRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -828,7 +848,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}
|
||||
|
||||
void refreshGlobalSessionsForDirectories(addedDirectories, syncSessionsSnapshotRef.current);
|
||||
}, [projectSessionDirectories]);
|
||||
}, [isVSCode, projectSessionDirectories]);
|
||||
|
||||
const { github } = useRuntimeAPIs();
|
||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||
@@ -1001,13 +1021,28 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions);
|
||||
|
||||
const activeNowSessions = React.useMemo(() => {
|
||||
if (!showRecentSection) {
|
||||
if (!showRecentSection || isVSCode) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return deriveActiveNowSessions(activeNowEntries, new Map(sessions.map((session) => [session.id, session])))
|
||||
.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
||||
}, [activeNowEntries, pinnedSessionIds, sessions, showRecentSection]);
|
||||
}, [activeNowEntries, isVSCode, pinnedSessionIds, sessions, showRecentSection]);
|
||||
|
||||
const vscodeSharedSessions = React.useMemo(() => {
|
||||
if (!isVSCode) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return sessions
|
||||
.filter((session) => !session.time?.archived)
|
||||
.filter((session) => !(session as Session & { parentID?: string | null }).parentID)
|
||||
.sort((left, right) => {
|
||||
const timeDelta = getSessionUpdatedAtMs(right) - getSessionUpdatedAtMs(left);
|
||||
if (timeDelta !== 0) return timeDelta;
|
||||
return right.id.localeCompare(left.id);
|
||||
});
|
||||
}, [isVSCode, sessions]);
|
||||
|
||||
const liveActiveSessions = React.useMemo(() => {
|
||||
if (!showRecentSection) {
|
||||
@@ -1041,25 +1076,50 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
// Prefetch is wired below, after recentSessionIds is computed.
|
||||
|
||||
const activitySections = React.useMemo(() => {
|
||||
if (!showRecentSection) {
|
||||
if (!isVSCode && !showRecentSection) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const recentSessions = isVSCode ? vscodeSharedSessions : activeNowSessions;
|
||||
|
||||
const toItem = (session: Session) => {
|
||||
const existing = sessionSidebarMetaById.get(session.id);
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
const node = existing?.node ?? { session, children: [], worktree: null };
|
||||
const filteredNodes = hasSessionSearchQuery
|
||||
? filterSessionNodesForSearch([node], normalizedSessionSearchQuery)
|
||||
: [node];
|
||||
const filteredNode = filteredNodes[0];
|
||||
if (!filteredNode) {
|
||||
return null;
|
||||
}
|
||||
const secondaryMeta = existing?.secondaryMeta
|
||||
? {
|
||||
projectLabel: existing.secondaryMeta.projectLabel,
|
||||
branchLabel: isVSCode ? null : existing.secondaryMeta.branchLabel,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
node: existing?.node ?? { session, children: [], worktree: null },
|
||||
node: filteredNode,
|
||||
projectId: existing?.projectId ?? null,
|
||||
groupDirectory: existing?.groupDirectory ?? sessionDirectory,
|
||||
secondaryMeta: existing?.secondaryMeta ?? null,
|
||||
secondaryMeta,
|
||||
};
|
||||
};
|
||||
|
||||
const items = recentSessions
|
||||
.map(toItem)
|
||||
.filter((item): item is NonNullable<ReturnType<typeof toItem>> => item !== null);
|
||||
|
||||
return [
|
||||
{ key: 'active-now' as const, title: t('sessions.sidebar.activity.recentTitle'), items: activeNowSessions.map(toItem) },
|
||||
{ key: 'active-now' as const, title: t('sessions.sidebar.activity.recentTitle'), items },
|
||||
];
|
||||
}, [activeNowSessions, sessionSidebarMetaById, showRecentSection, t]);
|
||||
}, [activeNowSessions, filterSessionNodesForSearch, hasSessionSearchQuery, isVSCode, normalizedSessionSearchQuery, sessionSidebarMetaById, showRecentSection, t, vscodeSharedSessions]);
|
||||
|
||||
const hasActivitySectionItems = React.useMemo(
|
||||
() => activitySections.some((section) => section.items.length > 0),
|
||||
[activitySections],
|
||||
);
|
||||
|
||||
|
||||
const recentSessionIds = React.useMemo(() => {
|
||||
@@ -1399,10 +1459,13 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
],
|
||||
);
|
||||
|
||||
const topContent = showRecentSection && !isVSCode && !hasSessionSearchQuery ? (
|
||||
const topContent = (isVSCode || (showRecentSection && !hasSessionSearchQuery)) ? (
|
||||
<SidebarActivitySections
|
||||
sections={activitySections}
|
||||
renderSessionNode={renderSessionNode}
|
||||
variant={isVSCode ? 'flat' : 'section'}
|
||||
initialVisibleCount={isVSCode ? VSCODE_RECENT_INITIAL_SESSION_COUNT : undefined}
|
||||
batchSize={isVSCode ? VSCODE_RECENT_SESSION_BATCH_SIZE : undefined}
|
||||
/>
|
||||
) : null;
|
||||
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId);
|
||||
@@ -1619,6 +1682,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
|
||||
<SidebarProjectsList
|
||||
topContent={topContent}
|
||||
sharedSessionsOnly={isVSCode}
|
||||
hasSharedSessions={hasActivitySectionItems}
|
||||
sectionsForRender={sectionsForSidebarRender}
|
||||
projectSections={projectSections}
|
||||
activeProjectId={activeProjectId}
|
||||
|
||||
@@ -23,14 +23,24 @@ type ActivitySection = {
|
||||
type Props = {
|
||||
sections: ActivitySection[];
|
||||
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null, renderContext?: 'project' | 'recent') => React.ReactNode;
|
||||
variant?: 'section' | 'flat';
|
||||
initialVisibleCount?: number;
|
||||
batchSize?: number;
|
||||
};
|
||||
|
||||
const MAX_VISIBLE_RECENT_SESSIONS = 7;
|
||||
|
||||
export function SidebarActivitySections({ sections, renderSessionNode }: Props): React.ReactNode {
|
||||
export function SidebarActivitySections({
|
||||
sections,
|
||||
renderSessionNode,
|
||||
variant = 'section',
|
||||
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
}: Props): React.ReactNode {
|
||||
const { t } = useI18n();
|
||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
||||
const [expandedSections, setExpandedSections] = React.useState<Set<string>>(new Set());
|
||||
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
|
||||
const flatVariant = variant === 'flat';
|
||||
|
||||
const toggleSection = React.useCallback((key: string) => {
|
||||
setCollapsed((prev) => {
|
||||
@@ -44,14 +54,24 @@ export function SidebarActivitySections({ sections, renderSessionNode }: Props):
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSectionLimit = React.useCallback((key: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
const showMoreSessions = React.useCallback((key: string, currentVisibleCount: number, totalCount: number) => {
|
||||
setVisibleCountBySection((prev) => {
|
||||
const nextVisibleCount = flatVariant
|
||||
? Math.min(totalCount, currentVisibleCount + batchSize)
|
||||
: totalCount;
|
||||
const next = new Map(prev);
|
||||
next.set(key, nextVisibleCount);
|
||||
return next;
|
||||
});
|
||||
}, [batchSize, flatVariant]);
|
||||
|
||||
const resetSectionLimit = React.useCallback((key: string) => {
|
||||
setVisibleCountBySection((prev) => {
|
||||
if (!prev.has(key)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
@@ -62,12 +82,34 @@ export function SidebarActivitySections({ sections, renderSessionNode }: Props):
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pb-2 pt-1">
|
||||
<div className={cn(flatVariant ? 'space-y-0.5 pb-2' : 'space-y-2 pb-2 pt-1')}>
|
||||
{visibleSections.map((section) => {
|
||||
const isCollapsed = collapsed.has(section.key);
|
||||
const isExpanded = expandedSections.has(section.key);
|
||||
const visibleItems = isExpanded ? section.items : section.items.slice(0, MAX_VISIBLE_RECENT_SESSIONS);
|
||||
const visibleLimit = Math.max(
|
||||
initialVisibleCount,
|
||||
visibleCountBySection.get(section.key) ?? initialVisibleCount,
|
||||
);
|
||||
const visibleItems = section.items.slice(0, visibleLimit);
|
||||
const remainingCount = section.items.length - visibleItems.length;
|
||||
const canShowFewer = !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0;
|
||||
|
||||
if (flatVariant) {
|
||||
return (
|
||||
<div key={section.key} className="space-y-0.5">
|
||||
{visibleItems.map((item) => renderSessionNode(item.node, 0, item.groupDirectory, item.projectId, false, item.secondaryMeta, 'recent'))}
|
||||
{remainingCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
{t('sessions.sidebar.group.showMore')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={section.key} className="space-y-1">
|
||||
<button
|
||||
@@ -84,23 +126,23 @@ export function SidebarActivitySections({ sections, renderSessionNode }: Props):
|
||||
{!isCollapsed ? (
|
||||
<div className={cn('space-y-0.5 pl-7')}>
|
||||
{visibleItems.map((item) => renderSessionNode(item.node, 0, item.groupDirectory, item.projectId, false, item.secondaryMeta, 'recent'))}
|
||||
{remainingCount > 0 && !isExpanded ? (
|
||||
{remainingCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSectionLimit(section.key)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
{remainingCount === 1
|
||||
? t('sessions.sidebar.group.showMoreSingle', { count: remainingCount })
|
||||
: t('sessions.sidebar.group.showMorePlural', { count: remainingCount })}
|
||||
</button>
|
||||
) : null}
|
||||
{isExpanded && section.items.length > MAX_VISIBLE_RECENT_SESSIONS ? (
|
||||
{canShowFewer ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSectionLimit(section.key)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
onClick={() => resetSectionLimit(section.key)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
{t('sessions.sidebar.group.showFewer')}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@@ -33,6 +33,8 @@ type ProjectSection = {
|
||||
|
||||
type Props = {
|
||||
topContent?: React.ReactNode;
|
||||
sharedSessionsOnly?: boolean;
|
||||
hasSharedSessions?: boolean;
|
||||
sectionsForRender: ProjectSection[];
|
||||
projectSections: ProjectSection[];
|
||||
activeProjectId: string | null;
|
||||
@@ -76,6 +78,15 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
);
|
||||
|
||||
if (props.sharedSessionsOnly) {
|
||||
return (
|
||||
<ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pr-2', props.mobileVariant ? '' : '')}>
|
||||
{props.topContent}
|
||||
{!props.hasSharedSessions ? (props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState) : null}
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.projectSections.length === 0) {
|
||||
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>{props.topContent}{props.emptyState}</ScrollableOverlay>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user