86acfee949
The delete/archive tombstone atoms lived in store/projects.ts, which imports store/session. Resume lives on the other side of that edge, so consulting the tombstones from the resume path would have closed an import cycle. Move the atoms and their mutators to store/session-removal and repoint every consumer; no behavior change.
1354 lines
45 KiB
TypeScript
1354 lines
45 KiB
TypeScript
import { atom } from 'nanostores'
|
|
|
|
import {
|
|
liveSessionProjectId,
|
|
NO_PROJECT_ID,
|
|
type SidebarProjectTree
|
|
} from '@/app/chat/sidebar/projects/workspace-groups'
|
|
import type { HermesGitBaseBranch, HermesGitBranch } from '@/global'
|
|
import { getHermesConfig, hermesApi, type HermesGateway } from '@/hermes'
|
|
import { translateNow } from '@/i18n'
|
|
import { desktopDefaultCwd, isDesktopFsRemoteMode, selectDesktopPaths, writeDesktopFileText } from '@/lib/desktop-fs'
|
|
import { desktopGit } from '@/lib/desktop-git'
|
|
import { isMissingRestEndpoint, isMissingRpcMethod } from '@/lib/gateway-rpc'
|
|
import { isUnderPath } from '@/lib/path-compare'
|
|
import { persistentAtom } from '@/lib/persisted'
|
|
import { $gateway, activeGateway, ensureActiveGatewayOpen } from '@/store/gateway'
|
|
import { setSidebarAgentsGrouped } from '@/store/layout'
|
|
import { notify } from '@/store/notifications'
|
|
import {
|
|
$activeGatewayProfile,
|
|
$profileScope,
|
|
ALL_PROFILES,
|
|
normalizeProfileKey,
|
|
requestFreshSession
|
|
} from '@/store/profile'
|
|
import {
|
|
$selectedStoredSessionId,
|
|
$sessions,
|
|
sessionMatchesStoredId,
|
|
setSessions,
|
|
workspaceCwdForNewSession
|
|
} from '@/store/session'
|
|
import { $removedSessionIds, $sessionMutationsInFlight } from '@/store/session-removal'
|
|
import type { ProjectInfo, ProjectsPayload } from '@/types/hermes'
|
|
|
|
// First-class, per-profile Projects (named, multi-folder workspaces). State is
|
|
// served by the live gateway's `projects.*` JSON-RPC methods, which wrap the
|
|
// per-profile projects.db store. The sidebar groups sessions by project folder
|
|
// membership; these atoms are the renderer's cached view.
|
|
|
|
export const $projects = atom<ProjectInfo[]>([])
|
|
export const $activeProjectId = atom<null | string>(null)
|
|
|
|
// The authoritative project -> repo -> lane tree (overview), served by
|
|
// `projects.tree`. Lanes carry counts + structure; per-project session rows are
|
|
// fetched lazily on drill-in via `fetchProjectSessions`. This is the single
|
|
// source of project membership — the desktop no longer derives it.
|
|
export const $projectTree = atom<SidebarProjectTree[]>([])
|
|
export const $projectTreeLoading = atom(false)
|
|
|
|
// False when the connected backend predates the projects.* JSON-RPC surface
|
|
// (same semver label, older install). Null until the first probe.
|
|
export const $projectsRpcAvailable = atom<boolean | null>(null)
|
|
|
|
function markProjectsRpcSuccess(): void {
|
|
$projectsRpcAvailable.set(true)
|
|
}
|
|
|
|
function markProjectsRpcFailure(err: unknown): void {
|
|
if (isMissingRpcMethod(err)) {
|
|
$projectsRpcAvailable.set(false)
|
|
}
|
|
}
|
|
|
|
function projectsStaleBackendError(): Error {
|
|
return new Error(translateNow('sidebar.projects.staleBackend'))
|
|
}
|
|
|
|
// True while the disk scan is in flight (drives the "finding repos" hint).
|
|
export const $reposScanning = atom(false)
|
|
|
|
// ── Project scope (the "you're inside a project" view, mirroring profile scope)─
|
|
// The sidebar's grouped view is a project switcher: ALL_PROJECTS shows the
|
|
// project overview (a list you drill into), and a concrete id means you've
|
|
// "entered" that project so only its worktrees/branches/sessions show. This is
|
|
// pure view state (localStorage), distinct from the durable active-project
|
|
// pointer in projects.db — though entering a project also makes it active so new
|
|
// chats land there, exactly as selecting a profile does.
|
|
export const ALL_PROJECTS = '__all_projects__'
|
|
|
|
const PROJECT_SCOPE_KEY = 'hermes.desktop.projectScope'
|
|
|
|
export const $projectScope = persistentAtom<string>(PROJECT_SCOPE_KEY, ALL_PROJECTS, {
|
|
decode: raw => raw || ALL_PROJECTS,
|
|
encode: value => value || ALL_PROJECTS
|
|
})
|
|
|
|
// Enter a project: scope the sidebar to it and make it the active project
|
|
// (best-effort — the durable pointer is nice-to-have, the view scope is the
|
|
// point). Never opens a session.
|
|
export function enterProject(id: string): void {
|
|
$projectScope.set(id)
|
|
|
|
// Only explicit, persisted projects (ids are `p_<hex>`) become active. Auto
|
|
// projects (ids are filesystem paths) and the Home bucket have no durable row
|
|
// to pin, so they're view-scope only.
|
|
if (id.startsWith('p_')) {
|
|
void setActiveProject(id).catch(() => undefined)
|
|
}
|
|
}
|
|
|
|
export function exitProjectScope(): void {
|
|
$projectScope.set(ALL_PROJECTS)
|
|
}
|
|
|
|
// A project's working root: its primary folder, else the first repo that has
|
|
// one. Empty for the path-less Home bucket. (The sidebar's `projectTreeCwd` is
|
|
// the same rule over the same tree — this is the store-side copy so the store
|
|
// doesn't reach into the sidebar's React module.)
|
|
export const projectRootCwd = (project: SidebarProjectTree | undefined): string =>
|
|
(project?.path || project?.repos.find(repo => repo.path)?.path || '').trim()
|
|
|
|
// ⌘K "go to project": flip the sidebar into grouped mode and enter the project
|
|
// — a pure scope switch, same as clicking the overview row (never spends main).
|
|
// With `newSession` (⌘-select / ⌘-Enter) it also lands on a fresh session draft
|
|
// anchored at the project root — stacked as a tab when main already holds a
|
|
// chat (palette opens are opens-from-nowhere). A path-less project (the Home
|
|
// bucket) gets a plain detached draft.
|
|
export function goToProject(id: string, options?: { newSession?: boolean }): void {
|
|
setSidebarAgentsGrouped(true)
|
|
enterProject(id)
|
|
|
|
if (!options?.newSession) {
|
|
return
|
|
}
|
|
|
|
const cwd = projectRootCwd($projectTree.get().find(node => node.id === id))
|
|
|
|
if (cwd) {
|
|
requestStartWorkSession(cwd, undefined, { openTab: true })
|
|
} else {
|
|
requestFreshSession()
|
|
}
|
|
}
|
|
|
|
// The cwd a NEW chat should start in.
|
|
//
|
|
// Priority (first hit wins):
|
|
// 1. Explicit sidebar project scope (drilled into a project / Home bucket)
|
|
// 2. Configured default project dir (detached otherwise — in BOTH local and
|
|
// remote mode; a bare new chat never inherits the sticky remembered cwd,
|
|
// #57911 / #84220)
|
|
//
|
|
// The "active project" is just an atom ($projectScope) — so inside a project a
|
|
// new session (cmd-n, the trunk "+") starts at that project's root (its primary
|
|
// repo = the default-branch checkout). Outside one it does NOT inherit the chat
|
|
// you were looking at: after a restart that's the just-resumed session, whose
|
|
// stored cwd is often a home-dir fallback, so every new chat landed there
|
|
// instead of the configured default (#71873, #80213, #77496).
|
|
export function resolveNewSessionCwd(): string {
|
|
const scope = $projectScope.get()
|
|
|
|
// Inside Home, "no folder" is the point: a new chat must stay detached rather
|
|
// than silently attaching to the configured default dir and leaving Home.
|
|
if (scope === NO_PROJECT_ID) {
|
|
return ''
|
|
}
|
|
|
|
if (scope !== ALL_PROJECTS) {
|
|
const cwd = projectRootCwd($projectTree.get().find(node => node.id === scope))
|
|
|
|
if (cwd) {
|
|
return cwd
|
|
}
|
|
}
|
|
|
|
return workspaceCwdForNewSession()
|
|
}
|
|
|
|
// The project (explicit or auto) that owns `cwd`, by longest path match across
|
|
// the live tree. Null when no project covers it (it'll surface as a fresh
|
|
// auto-project on the next tree refresh).
|
|
export function projectIdForCwd(cwd: string): null | string {
|
|
let best: null | string = null
|
|
let bestLen = -1
|
|
|
|
for (const project of $projectTree.get()) {
|
|
// Match project + repo roots AND each worktree-lane path: a linked worktree
|
|
// (e.g. a sibling `repo-retry`) lives OUTSIDE the repo root, so root-prefix
|
|
// matching alone would miss it — but it's still part of the project.
|
|
const paths = [project.path, ...project.repos.flatMap(repo => [repo.path, ...repo.groups.map(group => group.path)])]
|
|
|
|
for (const path of paths) {
|
|
const p = (path || '').trim()
|
|
|
|
if (p && isUnderPath(p, cwd) && p.length > bestLen) {
|
|
bestLen = p.length
|
|
best = project.id
|
|
}
|
|
}
|
|
}
|
|
|
|
return best
|
|
}
|
|
|
|
// The display NAME of the explicit, named project owning `cwd` (longest path
|
|
// match), or null when the cwd sits in no named project. The status bar reads
|
|
// this to label the workspace by project instead of the bare cwd leaf. We skip
|
|
// auto-projects (a repo root promoted with no projects.db row) and the synthetic
|
|
// Home bucket on purpose: those have no human name, so their sessions keep the
|
|
// cwd-leaf label — matching the backend `_project_info_for_cwd`, which
|
|
// only resolves projects.db rows, so the desktop and TUI name the same session
|
|
// identically without threading a second per-session copy through session.info.
|
|
export function projectNameForCwd(cwd: string): null | string {
|
|
const target = (cwd || '').trim()
|
|
|
|
if (!target) {
|
|
return null
|
|
}
|
|
|
|
let best: null | string = null
|
|
let bestLen = -1
|
|
|
|
for (const project of $projectTree.get()) {
|
|
if (project.isAuto || project.isNoProject) {
|
|
continue
|
|
}
|
|
|
|
const paths = [project.path, ...project.repos.flatMap(repo => [repo.path, ...repo.groups.map(group => group.path)])]
|
|
|
|
for (const path of paths) {
|
|
const p = (path || '').trim()
|
|
|
|
if (p && isUnderPath(p, target) && p.length > bestLen) {
|
|
bestLen = p.length
|
|
best = project.label
|
|
}
|
|
}
|
|
}
|
|
|
|
return best
|
|
}
|
|
|
|
// The active session's agent relocated itself (created/entered another repo or
|
|
// worktree via the terminal — backend re-anchors its cwd and emits session.info).
|
|
// Re-pull projects + tree so a freshly created/auto project and the relocated
|
|
// session row show live, then follow the view into the session's new project
|
|
// (from the overview or a now-stale project alike). Caller gates this on a real
|
|
// same-session cwd move, so a plain session switch never reaches here.
|
|
export async function followActiveSessionCwd(cwd: string): Promise<void> {
|
|
const target = cwd.trim()
|
|
|
|
if (!target) {
|
|
return
|
|
}
|
|
|
|
await Promise.all([refreshProjects(), refreshProjectTree()])
|
|
|
|
// Resolve only after the refresh, so a just-created/auto project is in the tree.
|
|
const projectId = projectIdForCwd(target)
|
|
|
|
if (projectId) {
|
|
// The Projects tree only renders in grouped mode, so flip the sidebar into
|
|
// it — otherwise following from the flat Sessions list would change scope
|
|
// invisibly. Then drill into the thread's project.
|
|
setSidebarAgentsGrouped(true)
|
|
|
|
if (projectId !== $projectScope.get()) {
|
|
enterProject(projectId)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Issue a request on whichever gateway is currently active, reconnecting once
|
|
// if the socket dropped. Projects are per-profile, so they intentionally follow
|
|
// the active gateway just like the session list does.
|
|
async function gatewayRequest<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
|
let gateway = activeGateway()
|
|
|
|
if (!gateway || gateway.connectionState !== 'open') {
|
|
gateway = await ensureActiveGatewayOpen()
|
|
}
|
|
|
|
if (!gateway) {
|
|
throw new Error('Hermes gateway is not connected')
|
|
}
|
|
|
|
return gateway.request<T>(method, params)
|
|
}
|
|
|
|
export function projectProfile(): null | string {
|
|
const profile = normalizeProfileKey($activeGatewayProfile.get())
|
|
|
|
return $profileScope.get() === ALL_PROFILES || profile === ALL_PROFILES ? null : profile
|
|
}
|
|
|
|
function projectParams(
|
|
params: Record<string, unknown> = {},
|
|
profile: null | string = projectProfile()
|
|
): Record<string, unknown> {
|
|
if (!profile) {
|
|
throw new Error('Projects are unavailable while viewing all profiles')
|
|
}
|
|
|
|
return { ...params, profile }
|
|
}
|
|
|
|
async function gatewayRequestOn<T>(
|
|
gateway: HermesGateway,
|
|
method: string,
|
|
params: Record<string, unknown> = {}
|
|
): Promise<T> {
|
|
return gateway.request<T>(method, params)
|
|
}
|
|
|
|
function isRetryableProjectTreeReadError(error: unknown): boolean {
|
|
const message = error instanceof Error ? error.message : String(error ?? '')
|
|
|
|
return message.includes('request timed out') || message.includes('gateway connection closed')
|
|
}
|
|
|
|
interface ActiveProjectsContext {
|
|
gateway: HermesGateway
|
|
profile: string
|
|
}
|
|
|
|
function stillOnProjectsContext(context: ActiveProjectsContext): boolean {
|
|
return activeGateway() === context.gateway && projectProfile() === context.profile
|
|
}
|
|
|
|
async function activeProjectsContext(): Promise<ActiveProjectsContext> {
|
|
const profile = projectProfile()
|
|
|
|
if (!profile) {
|
|
throw new Error('Projects are unavailable while viewing all profiles')
|
|
}
|
|
|
|
let gateway = activeGateway()
|
|
|
|
if (!gateway || gateway.connectionState !== 'open') {
|
|
gateway = await ensureActiveGatewayOpen()
|
|
}
|
|
|
|
if (!gateway || gateway !== activeGateway() || profile !== projectProfile()) {
|
|
throw new Error('Active Hermes profile changed while connecting')
|
|
}
|
|
|
|
return { gateway, profile }
|
|
}
|
|
|
|
function applyPayload(payload: ProjectsPayload): void {
|
|
$projects.set(payload.projects ?? [])
|
|
$activeProjectId.set(payload.active_id ?? null)
|
|
}
|
|
|
|
let projectsRefreshGeneration = 0
|
|
|
|
// Pull the full project list + active pointer. Best-effort: a failure (gateway
|
|
// not up yet) leaves the cached atoms intact so the sidebar doesn't flicker.
|
|
export async function refreshProjects(): Promise<void> {
|
|
const generation = ++projectsRefreshGeneration
|
|
let context: ActiveProjectsContext | null = null
|
|
|
|
try {
|
|
context = await activeProjectsContext()
|
|
|
|
const payload = await gatewayRequestOn<ProjectsPayload>(
|
|
context.gateway,
|
|
'projects.list',
|
|
projectParams({}, context.profile)
|
|
)
|
|
|
|
if (generation !== projectsRefreshGeneration || !stillOnProjectsContext(context)) {
|
|
return
|
|
}
|
|
|
|
applyPayload(payload)
|
|
markProjectsRpcSuccess()
|
|
} catch (err) {
|
|
if (context && generation === projectsRefreshGeneration && stillOnProjectsContext(context)) {
|
|
markProjectsRpcFailure(err)
|
|
}
|
|
// Backend may not be ready; keep the last known list.
|
|
}
|
|
}
|
|
|
|
interface ProjectTreePayload {
|
|
projects: SidebarProjectTree[]
|
|
active_id: null | string
|
|
scoped_session_ids: string[]
|
|
}
|
|
|
|
const PROJECT_TREE_PREVIEW_LIMIT = 3
|
|
// The all-profiles fan-out reads one database per profile, so it is allowed the
|
|
// same headroom as the cross-profile session list rather than the interactive
|
|
// default.
|
|
const PROJECT_TREE_REQUEST_TIMEOUT_MS = 60_000
|
|
|
|
let projectTreeRefreshGeneration = 0
|
|
|
|
function applyProjectTreePayload(res: ProjectTreePayload): void {
|
|
const scoped = new Set(res.scoped_session_ids ?? [])
|
|
$projectTree.set(res.projects ?? [])
|
|
$activeProjectId.set(res.active_id ?? null)
|
|
const tombstones = $removedSessionIds.get()
|
|
|
|
if (tombstones.size) {
|
|
// Keep a tombstone while the backend still lists the id (delete pending on
|
|
// its side) OR while its mutation is still in flight locally — dropping it
|
|
// early flashes the row back until the RPC lands.
|
|
const inFlight = $sessionMutationsInFlight.get()
|
|
const pending = new Set([...tombstones].filter(id => scoped.has(id) || inFlight.has(id)))
|
|
|
|
if (pending.size !== tombstones.size) {
|
|
$removedSessionIds.set(pending)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function refreshProjectTreeOn(context: ActiveProjectsContext): Promise<void> {
|
|
const generation = ++projectTreeRefreshGeneration
|
|
const { gateway, profile } = context
|
|
|
|
if (activeGateway() === gateway) {
|
|
$projectTreeLoading.set(true)
|
|
}
|
|
|
|
try {
|
|
let res: ProjectTreePayload
|
|
|
|
try {
|
|
res = await gatewayRequestOn<ProjectTreePayload>(
|
|
gateway,
|
|
'projects.tree',
|
|
projectParams({ preview_limit: PROJECT_TREE_PREVIEW_LIMIT }, profile)
|
|
)
|
|
} catch (error) {
|
|
// A remote source switch can leave the first read RPC on a newly-opened
|
|
// socket without a response even though the gateway remains healthy.
|
|
// Retry once only while this exact gateway/profile is still foreground;
|
|
// missing-method and other authoritative failures stay visible as-is.
|
|
if (!isRetryableProjectTreeReadError(error) || !stillOnProjectsContext(context)) {
|
|
throw error
|
|
}
|
|
|
|
res = await gatewayRequestOn<ProjectTreePayload>(
|
|
gateway,
|
|
'projects.tree',
|
|
projectParams({ preview_limit: PROJECT_TREE_PREVIEW_LIMIT }, profile)
|
|
)
|
|
}
|
|
|
|
if (generation !== projectTreeRefreshGeneration || !stillOnProjectsContext(context)) {
|
|
return
|
|
}
|
|
|
|
applyProjectTreePayload(res)
|
|
markProjectsRpcSuccess()
|
|
} catch (err) {
|
|
if (generation === projectTreeRefreshGeneration && stillOnProjectsContext(context)) {
|
|
markProjectsRpcFailure(err)
|
|
}
|
|
} finally {
|
|
if (generation === projectTreeRefreshGeneration && activeGateway() === gateway) {
|
|
$projectTreeLoading.set(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pull the authoritative project tree (overview structure + counts + preview
|
|
// sessions + the scoped-session-id set). Best-effort: a failure leaves the
|
|
// cached tree intact so the sidebar doesn't flicker.
|
|
export async function refreshProjectTree(): Promise<void> {
|
|
if ($profileScope.get() === ALL_PROFILES) {
|
|
await refreshProjectTreeAcrossProfiles()
|
|
|
|
return
|
|
}
|
|
|
|
try {
|
|
await refreshProjectTreeOn(await activeProjectsContext())
|
|
} catch {
|
|
// Backend may not be ready; keep the last known tree.
|
|
}
|
|
}
|
|
|
|
// The grouped sidebar in all-profiles mode. `projects.tree` answers for one
|
|
// backend's own profile, so it can only ever describe a slice of this view;
|
|
// the REST fan-out reads every profile's databases directly instead of asking
|
|
// us to hold a backend open per profile just to draw lanes.
|
|
async function refreshProjectTreeAcrossProfiles(): Promise<void> {
|
|
const generation = ++projectTreeRefreshGeneration
|
|
$projectTreeLoading.set(true)
|
|
|
|
try {
|
|
const res = await hermesApi<ProjectTreePayload>({
|
|
path: `/api/profiles/projects/tree?preview_limit=${PROJECT_TREE_PREVIEW_LIMIT}`,
|
|
timeoutMs: PROJECT_TREE_REQUEST_TIMEOUT_MS
|
|
})
|
|
|
|
// A profile switch mid-flight leaves this payload describing the wrong
|
|
// scope; the newer refresh owns the tree.
|
|
if (generation !== projectTreeRefreshGeneration || $profileScope.get() !== ALL_PROFILES) {
|
|
return
|
|
}
|
|
|
|
applyProjectTreePayload(res)
|
|
markProjectsRpcSuccess()
|
|
} catch (err) {
|
|
markProjectsRpcFailure(err)
|
|
} finally {
|
|
if (generation === projectTreeRefreshGeneration) {
|
|
$projectTreeLoading.set(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fully hydrated lanes (repo -> lane -> session rows) for one project, fetched
|
|
// when the user enters it. Same backend grouping as `projects.tree`, so ids and
|
|
// membership match exactly.
|
|
let projectSessionsRefreshGeneration = 0
|
|
|
|
export async function fetchProjectSessions(projectId: string): Promise<SidebarProjectTree | null> {
|
|
const generation = ++projectSessionsRefreshGeneration
|
|
|
|
try {
|
|
const context = await activeProjectsContext()
|
|
|
|
const res = await gatewayRequestOn<{ project: SidebarProjectTree | null }>(
|
|
context.gateway,
|
|
'projects.project_sessions',
|
|
projectParams({ project_id: projectId }, context.profile)
|
|
)
|
|
|
|
if (generation !== projectSessionsRefreshGeneration || !stillOnProjectsContext(context)) {
|
|
return null
|
|
}
|
|
|
|
return res.project ?? null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
interface WorkspaceMovePayload {
|
|
branch?: null | string
|
|
cwd?: string
|
|
git_repo_root?: null | string
|
|
}
|
|
|
|
// Re-home a stored session into another project's root folder — the fix for a
|
|
// chat created in the wrong directory. The backend replaces cwd + git identity
|
|
// (so the tree's grouping follows) and re-anchors any live agent bound to the
|
|
// row; here we mirror the move into the `$sessions` cache so both the flat list
|
|
// and the grouped tree reflect it before the next authoritative refresh.
|
|
export async function moveSessionToProject(
|
|
sessionId: string,
|
|
projectId: string,
|
|
profile?: null | string
|
|
): Promise<void> {
|
|
const cwd = projectRootCwd($projectTree.get().find(node => node.id === projectId))
|
|
|
|
if (!cwd) {
|
|
throw new Error(translateNow('sidebar.projects.moveNoFolder'))
|
|
}
|
|
|
|
const res = await gatewayRequest<WorkspaceMovePayload>('session.workspace.move', {
|
|
cwd,
|
|
session_key: sessionId,
|
|
...(profile ? { profile } : {})
|
|
})
|
|
|
|
const moved = res.cwd || cwd
|
|
setSessions(prev =>
|
|
prev.map(s =>
|
|
sessionMatchesStoredId(s, sessionId)
|
|
? { ...s, cwd: moved, git_branch: res.branch ?? null, git_repo_root: res.git_repo_root ?? null }
|
|
: s
|
|
)
|
|
)
|
|
void refreshProjectTree()
|
|
}
|
|
|
|
export interface RepoDiscoveryPolicy {
|
|
enabled: boolean
|
|
roots: string[]
|
|
exclude_paths: string[]
|
|
}
|
|
|
|
export function repoDiscoveryPolicyFromConfig(config: unknown): RepoDiscoveryPolicy {
|
|
const desktopValue = config && typeof config === 'object' ? (config as { desktop?: unknown }).desktop : undefined
|
|
|
|
const desktop =
|
|
desktopValue && typeof desktopValue === 'object'
|
|
? (desktopValue as {
|
|
repo_scan_enabled?: unknown
|
|
repo_scan_exclude_paths?: unknown
|
|
repo_scan_roots?: unknown
|
|
})
|
|
: {}
|
|
|
|
return {
|
|
enabled: desktop.repo_scan_enabled !== false,
|
|
roots: Array.isArray(desktop.repo_scan_roots)
|
|
? desktop.repo_scan_roots.filter((value): value is string => typeof value === 'string')
|
|
: [],
|
|
exclude_paths: Array.isArray(desktop.repo_scan_exclude_paths)
|
|
? desktop.repo_scan_exclude_paths.filter((value): value is string => typeof value === 'string')
|
|
: []
|
|
}
|
|
}
|
|
|
|
export function repoDiscoveryPolicySignature(policy: RepoDiscoveryPolicy): string {
|
|
return JSON.stringify(policy)
|
|
}
|
|
|
|
interface RepoScanState {
|
|
completedSignature?: string
|
|
generation: number
|
|
runningSignature?: string
|
|
}
|
|
|
|
const repoScanStates = new WeakMap<HermesGateway, RepoScanState>()
|
|
const scanningGatewayGenerations = new WeakMap<HermesGateway, number>()
|
|
|
|
function syncReposScanning(): void {
|
|
const gateway = activeGateway()
|
|
$reposScanning.set(Boolean(gateway && scanningGatewayGenerations.has(gateway)))
|
|
}
|
|
|
|
$gateway.subscribe(syncReposScanning)
|
|
|
|
export async function scanAndRecordRepos(force = false): Promise<void> {
|
|
if (isDesktopFsRemoteMode()) {
|
|
// On a remote backend the desktop can't crawl the host filesystem.
|
|
// Ask the host to scan its own discovery roots (`projects.discover_repos`
|
|
// with `scan: true` — added in #81723) so repos with zero Hermes
|
|
// sessions still surface, then refresh the tree so the sidebar picks up
|
|
// the merged session-derived + scanned list.
|
|
try {
|
|
const context = await activeProjectsContext()
|
|
|
|
const discovered = await gatewayRequestOn<{
|
|
repos?: unknown
|
|
discovery_policy?: unknown
|
|
}>(context.gateway, 'projects.discover_repos', projectParams({ scan: true }, context.profile))
|
|
|
|
// A resolved response must be the discovery shape. Anything else (an
|
|
// error/`accepted:false` body, or a backend that ignored `scan` and
|
|
// returned no repo list) means the scan didn't happen — bail out without
|
|
// touching the tree so the sidebar keeps its last known list instead of
|
|
// being blanked back to the silent, unpopulated state of #81723.
|
|
if (discovered?.repos === undefined) {
|
|
markProjectsRpcFailure(new Error('projects.discover_repos returned no repo list'))
|
|
|
|
return
|
|
}
|
|
|
|
// Remote scan succeeded: refresh the tree so the merged session-derived +
|
|
// scanned list surfaces. Skip if the user moved on — a stale scan must
|
|
// not publish into the newly focused profile.
|
|
if (stillOnProjectsContext(context)) {
|
|
await refreshProjectTreeOn(context)
|
|
}
|
|
} catch (err) {
|
|
// Surface the failure (stale backend, RPC error, gateway drop) instead
|
|
// of swallowing it: a silent return is exactly the "sidebar goes quiet"
|
|
// symptom `scan:true` was meant to fix (#81723). Keep the old list and
|
|
// let the sidebar show the error/absent state.
|
|
markProjectsRpcFailure(err)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
let context: ActiveProjectsContext
|
|
|
|
try {
|
|
context = await activeProjectsContext()
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
const scan = desktopGit()?.scanRepos
|
|
|
|
if (!scan) {
|
|
return
|
|
}
|
|
|
|
const state = repoScanStates.get(context.gateway) ?? { generation: 0 }
|
|
repoScanStates.set(context.gateway, state)
|
|
let generation: number | undefined
|
|
|
|
try {
|
|
const policy = repoDiscoveryPolicyFromConfig(await getHermesConfig(context.profile))
|
|
const signature = repoDiscoveryPolicySignature(policy)
|
|
|
|
if (!force && (state.completedSignature === signature || state.runningSignature === signature)) {
|
|
return
|
|
}
|
|
|
|
generation = ++state.generation
|
|
state.runningSignature = signature
|
|
|
|
if (!policy.enabled) {
|
|
await gatewayRequestOn(
|
|
context.gateway,
|
|
'projects.record_repos',
|
|
projectParams({ discovery_policy: policy, repos: [] }, context.profile)
|
|
)
|
|
} else {
|
|
scanningGatewayGenerations.set(context.gateway, generation)
|
|
syncReposScanning()
|
|
|
|
const repos = await scan(policy.roots, {
|
|
enabled: true,
|
|
excludePaths: policy.exclude_paths
|
|
})
|
|
|
|
if (state.generation !== generation) {
|
|
return
|
|
}
|
|
|
|
await gatewayRequestOn(
|
|
context.gateway,
|
|
'projects.record_repos',
|
|
projectParams({ discovery_policy: policy, repos }, context.profile)
|
|
)
|
|
}
|
|
|
|
if (state.generation !== generation) {
|
|
return
|
|
}
|
|
|
|
state.completedSignature = signature
|
|
|
|
// Completion refresh only when the focused profile still matches the one
|
|
// the scan was captured under. refreshProjectTree() re-derives the current
|
|
// context, so skipping on mismatch keeps a stale scan from publishing into
|
|
// the newly focused profile.
|
|
if (stillOnProjectsContext(context)) {
|
|
await refreshProjectTree()
|
|
}
|
|
} catch {
|
|
state.completedSignature = undefined
|
|
} finally {
|
|
state.runningSignature = undefined
|
|
|
|
if (scanningGatewayGenerations.get(context.gateway) === generation) {
|
|
scanningGatewayGenerations.delete(context.gateway)
|
|
}
|
|
|
|
syncReposScanning()
|
|
}
|
|
}
|
|
|
|
export interface CreateProjectInput {
|
|
name: string
|
|
folders?: string[]
|
|
primaryPath?: string
|
|
slug?: string
|
|
description?: string
|
|
icon?: string
|
|
color?: string
|
|
boardSlug?: string
|
|
use?: boolean
|
|
// Free-text project idea; written to IDEA.md at the primary folder on create.
|
|
idea?: string
|
|
}
|
|
|
|
// Generate a project idea via the stateless llm.oneshot RPC (inherits the live
|
|
// session's model when one exists). Returns "" on failure so the caller can just
|
|
// leave the field untouched. The "🎲" affordance in the new-project dialog.
|
|
export async function generateProjectIdea(name: string): Promise<string> {
|
|
try {
|
|
const res = await gatewayRequest<{ text: string }>('llm.oneshot', {
|
|
instructions:
|
|
'You generate a single, concrete project idea as a short IDEA.md body: a one-line summary, ' +
|
|
'then 3-5 bullet goals. No preamble, no code fences, under 120 words.',
|
|
input: name.trim() ? `Project name: ${name.trim()}` : 'Surprise me with a fun project.',
|
|
temperature: 1.0
|
|
})
|
|
|
|
return (res.text || '').trim()
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
// Write IDEA.md to a project's primary folder (best-effort). Routes through the
|
|
// remote-aware fs write, so it lands on the backend for a remote gateway and on
|
|
// disk locally — the project is created regardless of whether the file lands.
|
|
async function writeProjectIdea(folder: null | string | undefined, idea: string): Promise<void> {
|
|
const dir = (folder || '').trim()
|
|
const body = idea.trim()
|
|
|
|
if (!dir || !body) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
await writeDesktopFileText(`${dir.replace(/[/\\]+$/, '')}/IDEA.md`, body.endsWith('\n') ? body : `${body}\n`)
|
|
} catch {
|
|
// Best-effort: the project is created regardless of whether IDEA.md lands.
|
|
}
|
|
}
|
|
|
|
// ── Optimistic cache layer ───────────────────────────────────────────────────
|
|
// The project cache (list + tree + active pointer) mutates instantly on user
|
|
// action; the write reconciles in the background and rolls the whole cache back
|
|
// on failure — the same Apollo-style layer the session list uses.
|
|
|
|
interface ProjectsSnapshot {
|
|
projects: ProjectInfo[]
|
|
tree: SidebarProjectTree[]
|
|
active: null | string
|
|
}
|
|
|
|
const snapshotProjects = (): ProjectsSnapshot => ({
|
|
projects: $projects.get(),
|
|
tree: $projectTree.get(),
|
|
active: $activeProjectId.get()
|
|
})
|
|
|
|
const restoreProjects = ({ projects, tree, active }: ProjectsSnapshot): void => {
|
|
$projects.set(projects)
|
|
$projectTree.set(tree)
|
|
$activeProjectId.set(active)
|
|
}
|
|
|
|
// Await an already-applied optimistic write; restore the snapshot if it throws.
|
|
async function persistOrRollback(snap: ProjectsSnapshot, write: () => Promise<void>): Promise<void> {
|
|
try {
|
|
await write()
|
|
} catch (err) {
|
|
restoreProjects(snap)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
const reconcileProjects = (): void => {
|
|
void refreshProjects()
|
|
void refreshProjectTree()
|
|
}
|
|
|
|
// Map a ProjectInfo (list shape) onto a minimal overview tree node so a created
|
|
// project paints instantly. The backend seeds each folder as an (empty) repo, so
|
|
// the next tree refresh fills in repos/counts; this is just the optimistic stub.
|
|
function projectInfoToTreeNode(project: ProjectInfo): SidebarProjectTree {
|
|
return {
|
|
id: project.id,
|
|
label: project.name || project.id,
|
|
path: project.primary_path ?? project.folders?.[0]?.path ?? null,
|
|
color: project.color ?? null,
|
|
icon: project.icon ?? null,
|
|
isAuto: false,
|
|
repos: [],
|
|
sessionCount: 0,
|
|
previewSessions: []
|
|
}
|
|
}
|
|
|
|
export async function createProject(input: CreateProjectInput): Promise<ProjectInfo | null> {
|
|
if ($projectsRpcAvailable.get() === false) {
|
|
throw projectsStaleBackendError()
|
|
}
|
|
|
|
let res: { project: ProjectInfo | null }
|
|
|
|
try {
|
|
res = await gatewayRequest<{ project: ProjectInfo | null }>(
|
|
'projects.create',
|
|
projectParams({
|
|
name: input.name,
|
|
folders: input.folders ?? [],
|
|
primary_path: input.primaryPath,
|
|
slug: input.slug,
|
|
description: input.description,
|
|
icon: input.icon,
|
|
color: input.color,
|
|
board_slug: input.boardSlug,
|
|
use: input.use ?? false
|
|
})
|
|
)
|
|
} catch (err) {
|
|
if (isMissingRpcMethod(err)) {
|
|
$projectsRpcAvailable.set(false)
|
|
throw projectsStaleBackendError()
|
|
}
|
|
|
|
throw err
|
|
}
|
|
|
|
markProjectsRpcSuccess()
|
|
|
|
// Not optimistic (the create awaits the RPC first, so there's nothing to roll
|
|
// back): apply the server's row into the cached list + tree at once, so it
|
|
// (and an entered scope) shows without waiting on the background refreshes
|
|
// that reconcile counts/repos.
|
|
const created = res.project
|
|
|
|
if (created) {
|
|
if (input.idea) {
|
|
void writeProjectIdea(created.primary_path ?? created.folders?.[0]?.path ?? input.primaryPath, input.idea)
|
|
}
|
|
|
|
if (!$projects.get().some(proj => proj.id === created.id)) {
|
|
$projects.set([...$projects.get(), created])
|
|
}
|
|
|
|
if (!$projectTree.get().some(node => node.id === created.id)) {
|
|
$projectTree.set([projectInfoToTreeNode(created), ...$projectTree.get()])
|
|
}
|
|
|
|
if (input.use) {
|
|
$activeProjectId.set(created.id)
|
|
}
|
|
|
|
setSidebarAgentsGrouped(true)
|
|
}
|
|
|
|
reconcileProjects()
|
|
|
|
return created
|
|
}
|
|
|
|
export async function renameProject(id: string, name: string): Promise<void> {
|
|
await updateProject(id, { name })
|
|
}
|
|
|
|
// Patch top-level project fields (name / appearance). Optimistic: the cached
|
|
// tree + list update instantly so a color/icon/name change has no round-trip
|
|
// lag; only a failed write reconciles from the server.
|
|
export async function updateProject(
|
|
id: string,
|
|
patch: { name?: string; color?: null | string; icon?: null | string }
|
|
): Promise<void> {
|
|
const snap = snapshotProjects()
|
|
|
|
$projectTree.set(
|
|
snap.tree.map(node =>
|
|
node.id === id
|
|
? {
|
|
...node,
|
|
...(patch.name !== undefined && { label: patch.name }),
|
|
...(patch.color !== undefined && { color: patch.color }),
|
|
...(patch.icon !== undefined && { icon: patch.icon })
|
|
}
|
|
: node
|
|
)
|
|
)
|
|
$projects.set(snap.projects.map(proj => (proj.id === id ? { ...proj, ...patch } : proj)))
|
|
|
|
// Backend treats null/undefined as "leave unchanged"; "" clears (stores NULL).
|
|
// Map explicit null → "" so "no color"/"no icon" actually clear.
|
|
await persistOrRollback(snap, () =>
|
|
gatewayRequest(
|
|
'projects.update',
|
|
projectParams({
|
|
id,
|
|
...patch,
|
|
...(patch.color === null && { color: '' }),
|
|
...(patch.icon === null && { icon: '' })
|
|
})
|
|
)
|
|
)
|
|
}
|
|
|
|
// Appearance for an AUTO (inherited git-repo) project has no projects.db row to
|
|
// write to — its id is just the repo path. So the first color/icon change ADOPTS
|
|
// the repo as a real project (folder = repo root, name = its label) carrying the
|
|
// chosen look; from then on it patches in place like any explicit project.
|
|
// Returns true when an adoption happened, so an incremental picker can close
|
|
// (the node's id changes on adopt, and a second stale write would double-create).
|
|
export async function setProjectAppearance(
|
|
project: Pick<SidebarProjectTree, 'color' | 'icon' | 'id' | 'isAuto' | 'label' | 'path'>,
|
|
patch: { color?: null | string; icon?: null | string }
|
|
): Promise<boolean> {
|
|
if (!project.isAuto) {
|
|
await updateProject(project.id, patch)
|
|
|
|
return false
|
|
}
|
|
|
|
if (!project.path) {
|
|
return false
|
|
}
|
|
|
|
await createProject({
|
|
name: project.label,
|
|
folders: [project.path],
|
|
primaryPath: project.path,
|
|
// Carry any already-set look so setting one field doesn't wipe the other.
|
|
color: (patch.color ?? project.color) || undefined,
|
|
icon: (patch.icon ?? project.icon) || undefined
|
|
})
|
|
|
|
return true
|
|
}
|
|
|
|
export async function addProjectFolder(
|
|
id: string,
|
|
path: string,
|
|
opts: { label?: string; isPrimary?: boolean } = {}
|
|
): Promise<void> {
|
|
const snap = snapshotProjects()
|
|
const trimmed = path.trim()
|
|
|
|
// Optimistic: append the folder to the cached project + reflect a primary-path
|
|
// change on its tree node, so the dialog closes onto an updated row. The folder
|
|
// -> repo seeding (and session regrouping) is backend-computed, so the
|
|
// background refresh fills repos in; a failure rolls the cache back.
|
|
if (trimmed) {
|
|
const folder = { path: trimmed, label: opts.label ?? null, is_primary: opts.isPrimary ?? false, added_at: 0 }
|
|
|
|
$projects.set(
|
|
snap.projects.map(proj => {
|
|
if (proj.id !== id || proj.folders?.some(f => f.path === trimmed)) {
|
|
return proj
|
|
}
|
|
|
|
const folders = opts.isPrimary
|
|
? [folder, ...proj.folders.map(f => ({ ...f, is_primary: false }))]
|
|
: [...proj.folders, folder]
|
|
|
|
return { ...proj, folders, ...(opts.isPrimary && { primary_path: trimmed }) }
|
|
})
|
|
)
|
|
|
|
if (opts.isPrimary) {
|
|
$projectTree.set(snap.tree.map(node => (node.id === id ? { ...node, path: trimmed } : node)))
|
|
}
|
|
}
|
|
|
|
await persistOrRollback(snap, () =>
|
|
gatewayRequest(
|
|
'projects.add_folder',
|
|
projectParams({ id, path, label: opts.label, is_primary: opts.isPrimary ?? false })
|
|
)
|
|
)
|
|
reconcileProjects()
|
|
}
|
|
|
|
// True when the session currently open in the main pane belongs to `projectId`.
|
|
// Used so deleting a project you have a session open from kicks you back to the
|
|
// intro draft instead of stranding you in a now-orphaned view.
|
|
function openSessionBelongsToProject(projectId: string, projects: ProjectInfo[]): boolean {
|
|
const openId = $selectedStoredSessionId.get()
|
|
|
|
if (!openId) {
|
|
return false
|
|
}
|
|
|
|
const open = $sessions.get().find(s => sessionMatchesStoredId(s, openId))
|
|
|
|
return Boolean(open && liveSessionProjectId(open, projects) === projectId)
|
|
}
|
|
|
|
// Optimistic: drop the project from the cached tree + list the instant it's
|
|
// clicked (the entered-scope effect exits if you deleted the project you were
|
|
// inside), reconciling from the server payload. A failed delete restores both.
|
|
export async function deleteProject(id: string): Promise<void> {
|
|
const snap = snapshotProjects()
|
|
// Capture membership BEFORE removal — the project's folders (which determine
|
|
// ownership) are gone once it's dropped from the cache.
|
|
const kickToIntro = openSessionBelongsToProject(id, snap.projects)
|
|
|
|
$projects.set(snap.projects.filter(project => project.id !== id))
|
|
$projectTree.set(snap.tree.filter(node => node.id !== id))
|
|
|
|
if (snap.active === id) {
|
|
$activeProjectId.set(null)
|
|
}
|
|
|
|
// The open session's project is gone — reset to the intro draft (the session
|
|
// itself survives; it just falls back to Recents).
|
|
if (kickToIntro) {
|
|
requestFreshSession()
|
|
}
|
|
|
|
await persistOrRollback(snap, async () => {
|
|
applyPayload(await gatewayRequest<ProjectsPayload>('projects.delete', projectParams({ id })))
|
|
})
|
|
void refreshProjectTree()
|
|
}
|
|
|
|
export async function setActiveProject(id: null | string): Promise<void> {
|
|
const res = await gatewayRequest<{ active_id: null | string }>('projects.set_active', projectParams({ id }))
|
|
$activeProjectId.set(res.active_id ?? null)
|
|
}
|
|
|
|
// ── Project management dialog ────────────────────────────────────────────────
|
|
// A single dialog mounted in the sidebar reads this atom, so a project node's
|
|
// menu can open create / rename / add-folder flows without prop threading
|
|
// (mirrors $profileCreateRequest).
|
|
export interface ProjectDialogState {
|
|
mode: 'add-folder' | 'create' | 'rename'
|
|
projectId?: string
|
|
name?: string
|
|
}
|
|
|
|
export const $projectDialog = atom<null | ProjectDialogState>(null)
|
|
|
|
export function openProjectCreate(): void {
|
|
if ($projectsRpcAvailable.get() === false) {
|
|
notify({
|
|
kind: 'warning',
|
|
message: translateNow('sidebar.projects.staleBackend')
|
|
})
|
|
|
|
return
|
|
}
|
|
|
|
$projectDialog.set({ mode: 'create' })
|
|
}
|
|
|
|
export function openProjectRename(project: { id: string; name: string }): void {
|
|
$projectDialog.set({ mode: 'rename', name: project.name, projectId: project.id })
|
|
}
|
|
|
|
export function openProjectAddFolder(project: { id: string; name: string }): void {
|
|
$projectDialog.set({ mode: 'add-folder', name: project.name, projectId: project.id })
|
|
}
|
|
|
|
export function closeProjectDialog(): void {
|
|
$projectDialog.set(null)
|
|
}
|
|
|
|
// ── Git-driven worktrees ("Start work") ─────────────────────────────────────
|
|
// Bumped after a `git worktree add`/`remove` so the sidebar's worktree-list
|
|
// probe (useRepoWorktreeMap) refetches and the new/removed lane shows at once,
|
|
// instead of waiting for the next scope change.
|
|
export const $worktreeRefreshToken = atom(0)
|
|
const bumpWorktrees = () => $worktreeRefreshToken.set($worktreeRefreshToken.get() + 1)
|
|
|
|
// Re-run the visual `git worktree list` probe without the heavy projects.tree
|
|
// scan. Desktop-initiated add/remove already bumps the token inline; this is for
|
|
// OUT-OF-BAND changes the renderer can't see: the agent runs `git worktree
|
|
// add/remove` in the terminal during a turn, or an external terminal mutates the
|
|
// repo while the window was away. The probe is per-repo and bounded, so the
|
|
// caller (a settled turn / window refocus) can re-sync the worktree lanes
|
|
// cheaply, the same way a git GUI refreshes its tree on focus.
|
|
export function refreshWorktrees(): void {
|
|
bumpWorktrees()
|
|
}
|
|
|
|
// Spin up a fresh worktree the lightest way (`git worktree add -b`) under the
|
|
// repo, returning where Hermes should start working. Git is the source of
|
|
// truth; the caller starts a session in the returned path.
|
|
export async function startWorkInRepo(
|
|
repoPath: string,
|
|
options?: { name?: string; branch?: string; base?: string; existingBranch?: string }
|
|
): Promise<null | { path: string; branch: string }> {
|
|
const git = desktopGit()
|
|
|
|
if (!git || !repoPath) {
|
|
return null
|
|
}
|
|
|
|
let result
|
|
|
|
try {
|
|
result = await git.worktreeAdd(repoPath, options)
|
|
} catch (err) {
|
|
// Capability gate (#81724): a remote gateway serves worktree ops via the
|
|
// backend's /api/git mirror, and an older backend may predate it. The raw
|
|
// failure ("Expected JSON … but got HTML" / a bare 404) reads like a git
|
|
// error — name the real remedy instead of degrading silently.
|
|
if (isDesktopFsRemoteMode() && isMissingRestEndpoint(err)) {
|
|
throw new Error(translateNow('sidebar.projects.worktreeStaleBackend'))
|
|
}
|
|
|
|
throw err
|
|
}
|
|
|
|
bumpWorktrees()
|
|
|
|
return { branch: result.branch, path: result.path }
|
|
}
|
|
|
|
// Branches for the composer's "convert a branch into a worktree" picker: the
|
|
// local heads, plus the remote-tracking refs that have no local branch yet. A
|
|
// teammate's branch is therefore reachable, and the user does not check it out
|
|
// by hand first.
|
|
// Empty on a non-repo. On a remote gateway the list comes from the backend's
|
|
// /api/git/branches mirror, so it acts on the repo where sessions actually run.
|
|
export async function listRepoBranches(repoPath: string): Promise<HermesGitBranch[]> {
|
|
const git = desktopGit()
|
|
|
|
if (!git?.branchList || !repoPath) {
|
|
return []
|
|
}
|
|
|
|
return git.branchList(repoPath)
|
|
}
|
|
|
|
// Local + remote-tracking branches for the base-branch picker in the
|
|
// new-worktree dialog. The remote default (origin/HEAD) is flagged so the
|
|
// UI can preselect it. Empty on a non-repo; remote gateways serve it from the
|
|
// backend's /api/git/base-branches mirror.
|
|
export async function listBaseBranches(repoPath: string): Promise<HermesGitBaseBranch[]> {
|
|
const git = desktopGit()
|
|
|
|
if (!git?.baseBranchList || !repoPath) {
|
|
return []
|
|
}
|
|
|
|
return git.baseBranchList(repoPath)
|
|
}
|
|
|
|
export async function switchBranchInRepo(repoPath: string, branch: string): Promise<void> {
|
|
const git = desktopGit()
|
|
|
|
if (!git || !repoPath || !branch.trim()) {
|
|
return
|
|
}
|
|
|
|
await git.branchSwitch(repoPath, branch)
|
|
bumpWorktrees()
|
|
}
|
|
|
|
// A composer-driven "branch off into a new worktree" hand-off. The composer
|
|
// owns the typed draft; the chat controller owns session lifecycle. The composer
|
|
// creates the worktree (startWorkInRepo), then fires this so the controller opens
|
|
// a fresh session in that worktree and prefills the draft that kicked off the
|
|
// task. A monotonic token lets a rapid second request re-fire the controller's
|
|
// effect even if the path repeats.
|
|
export interface StartWorkSessionRequest {
|
|
draft?: string
|
|
/** Stack the fresh session as a tab when main already holds a chat (palette/⌘O opens-from-nowhere). */
|
|
openTab?: boolean
|
|
path: string
|
|
token: number
|
|
}
|
|
|
|
export const $startWorkSessionRequest = atom<StartWorkSessionRequest | null>(null)
|
|
|
|
// The "make a new worktree" intent, from the keyboard or a menu. One dialog is
|
|
// mounted, in the sidebar beside ProjectDialog, and it reads this atom. This
|
|
// mirrors $projectDialog. This atom was a monotonic token that every mounted
|
|
// coding rail subscribed to. N composers on screen therefore gave N stacked
|
|
// dialogs for one ⌘⇧B, and the dialog the user dismissed showed an identical
|
|
// empty one behind it. One mount cannot double-open.
|
|
//
|
|
// `repoPath` is resolved when the dialog opens (see resolveWorktreeRepoPath).
|
|
// It is not read from the rail that received the key, so the dialog always
|
|
// targets the surface the user looks at.
|
|
export interface WorktreeDialogState {
|
|
repoPath: string
|
|
/** The base branch selected in a "branch off from X" menu. */
|
|
base?: string
|
|
}
|
|
|
|
export const $worktreeDialog = atom<null | WorktreeDialogState>(null)
|
|
|
|
export function closeWorktreeDialog(): void {
|
|
$worktreeDialog.set(null)
|
|
}
|
|
|
|
let startWorkToken = 0
|
|
|
|
export function requestStartWorkSession(path: string, draft?: string, options?: { openTab?: boolean }): void {
|
|
const target = path.trim()
|
|
|
|
if (!target) {
|
|
return
|
|
}
|
|
|
|
startWorkToken += 1
|
|
$startWorkSessionRequest.set({
|
|
draft: draft?.trim() || undefined,
|
|
openTab: options?.openTab || undefined,
|
|
path: target,
|
|
token: startWorkToken
|
|
})
|
|
}
|
|
|
|
export async function removeWorktreePath(
|
|
repoPath: string,
|
|
worktreePath: string,
|
|
options?: { force?: boolean }
|
|
): Promise<void> {
|
|
const git = desktopGit()
|
|
|
|
if (!git) {
|
|
return
|
|
}
|
|
|
|
await git.worktreeRemove(repoPath, worktreePath, options)
|
|
bumpWorktrees()
|
|
}
|
|
|
|
// Reveal a project/worktree path in the OS file manager (git-GUI standard).
|
|
export async function revealPath(path: null | string): Promise<void> {
|
|
if (path) {
|
|
await window.hermesDesktop?.revealPath?.(path)
|
|
}
|
|
}
|
|
|
|
// Copy a path to the clipboard (git-GUI standard).
|
|
export async function copyPath(path: null | string): Promise<void> {
|
|
if (path) {
|
|
await window.hermesDesktop?.writeClipboard?.(path)
|
|
}
|
|
}
|
|
|
|
// Pick a project folder via the remote-aware picker: a remote gateway browses
|
|
// the backend filesystem (seeded at its default cwd) where sessions run; local
|
|
// mode opens the native dialog. Returns the absolute path, or null if cancelled.
|
|
export async function pickProjectFolder(): Promise<null | string> {
|
|
const [dir] = await selectDesktopPaths({
|
|
defaultPath: (await desktopDefaultCwd())?.cwd,
|
|
directories: true,
|
|
multiple: false
|
|
})
|
|
|
|
return dir || null
|
|
}
|
|
|
|
// ⌘O / palette "Open folder…": open a folder AS a project, upserting. A folder
|
|
// already covered by a project (explicit or auto) just enters it; anything else
|
|
// becomes a new project named after the folder. Either way the sidebar scopes
|
|
// to the project and a fresh session draft lands anchored at the folder — the
|
|
// one-keystroke version of new project → enter → new session. Like goToProject,
|
|
// this is an open-from-nowhere: an occupied main gets a stacked tab, not stolen.
|
|
export async function openFolderAsProject(dir?: string): Promise<void> {
|
|
const target = (dir ?? (await pickProjectFolder()) ?? '').trim()
|
|
|
|
if (!target) {
|
|
return
|
|
}
|
|
|
|
// Refresh first so the membership check runs against live truth — a repo
|
|
// cloned since the last scan should enter its auto project, not double-create.
|
|
await refreshProjectTree()
|
|
|
|
const existing = projectIdForCwd(target)
|
|
|
|
if (existing) {
|
|
setSidebarAgentsGrouped(true)
|
|
enterProject(existing)
|
|
} else {
|
|
const name =
|
|
target
|
|
.replace(/[/\\]+$/, '')
|
|
.split(/[/\\]/)
|
|
.pop() || target
|
|
|
|
try {
|
|
const created = await createProject({ name, folders: [target], primaryPath: target, use: true })
|
|
|
|
if (created) {
|
|
enterProject(created.id)
|
|
}
|
|
} catch (err) {
|
|
// Stale backend (no projects.* RPC) or a failed write: still open the
|
|
// folder as a plain workspace session below — the project row can wait.
|
|
notify({ kind: 'warning', message: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
}
|
|
|
|
requestStartWorkSession(target, undefined, { openTab: true })
|
|
}
|