Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/devframe/hub-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
addHubRoot,
loadHubConfig,
setEnabledProjects,
setExcludedProjects,
} from '../hub/config'
import { slugifyRepoName } from '../server/portless'
import { buildProjectContext, closeProjectContext } from './project-factory'
Expand Down Expand Up @@ -41,6 +42,9 @@ export async function setupHubMode(
const homeDir = options.homeDir
const roots = new Set<string>()
const projects = new Map<string, ProjectContext>()
// Absolute paths of enabled projects hidden from the hub. Kept loaded; the
// client filters them out of hub surfaces.
const excluded = new Set<string>()

// Single in-flight promise serializes config-mutating operations so two
// concurrent RPCs don't race on the shared hub.json file.
Expand Down Expand Up @@ -127,6 +131,8 @@ export async function setupHubMode(
config = await addHubRoot({ homeDir, path: launchCwd })
for (const root of config.roots)
roots.add(root)
for (const entry of config.excludedProjects ?? [])
excluded.add(entry.path)
await loadEnabledProjects(config.enabledProjects.map(p => p.path))

const registry: ProjectRegistry = {
Expand All @@ -148,19 +154,28 @@ export async function setupHubMode(
})
}

async function persistExcluded(): Promise<void> {
await setExcludedProjects({
homeDir,
paths: Array.from(excluded),
})
}

setProjectRegistry(devframeCtx, registry)
setHubContext(devframeCtx, {
devframeCtx,
homeDir,
launchCwd,
roots,
projects,
excluded,
withLock,
buildHubInfo,
broadcastProjectsChange,
broadcastHubInfoChange,
loadProjectByPath,
persistEnabled,
persistExcluded,
autoSync,
})
registerGhfsRpc(devframeCtx)
Expand Down
7 changes: 5 additions & 2 deletions src/devframe/rpc/capabilities.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { defineRpcFunction } from 'devframe'
import { GHFS_VERSION } from '../../meta'
import { summarizeProject } from './helpers'
import { getProjectRegistry } from './utils'
import { getProjectRegistry, tryGetHubContext } from './utils'

export const capabilities = defineRpcFunction({
name: 'ghfs:capabilities',
type: 'static',
setup: (context) => {
const registry = getProjectRegistry(context)
const hub = tryGetHubContext(context)
return {
handler: async () => ({
mode: registry.mode,
ghfsVersion: GHFS_VERSION,
projects: await Promise.all(registry.listProjects().map(summarizeProject)),
projects: await Promise.all(
registry.listProjects().map(ctx => summarizeProject(ctx, { excluded: hub?.excluded.has(ctx.path) ?? false })),
),
}),
}
},
Expand Down
8 changes: 7 additions & 1 deletion src/devframe/rpc/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ export async function buildRepoMeta(ctx: ProjectContext): Promise<RepoMeta> {
}
}

export async function summarizeProject(ctx: ProjectContext): Promise<ProjectSummary> {
export interface SummarizeProjectOptions {
/** Whether the user has hidden this project from the hub. */
excluded?: boolean
}

export async function summarizeProject(ctx: ProjectContext, options: SummarizeProjectOptions = {}): Promise<ProjectSummary> {
const [repo, syncState, snapshot] = await Promise.all([
buildRepoMeta(ctx),
loadSyncState(ctx.storageDirAbsolute),
Expand Down Expand Up @@ -107,6 +112,7 @@ export async function summarizeProject(ctx: ProjectContext): Promise<ProjectSumm
lastSyncedAt: syncState.lastSyncedAt,
lastActivityAt,
labels,
excluded: options.excluded ?? false,
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/devframe/rpc/hub-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ export interface HubRpcContext {
launchCwd: string
roots: Set<string>
projects: Map<string, ProjectContext>
/** Absolute paths of enabled projects the user has hidden from the hub. */
excluded: Set<string>
withLock: <T>(fn: () => Promise<T>) => Promise<T>
buildHubInfo: () => HubInfo
broadcastProjectsChange: () => void
broadcastHubInfoChange: () => void
loadProjectByPath: (path: string) => Promise<ProjectContext>
persistEnabled: () => Promise<void>
/** Persist the current `excluded` set to hub.json. */
persistExcluded: () => Promise<void>
autoSync: { setInterval: (ms: number | undefined) => void, close: () => void }
}
3 changes: 3 additions & 0 deletions src/devframe/rpc/hub-disable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export const hubDisable = defineRpcFunction({
hub.projects.delete(id)
await closeProjectContext(ctx)
await hub.persistEnabled()
// A disabled project should not linger in the excluded set.
if (hub.excluded.delete(ctx.path))
await hub.persistExcluded()
hub.broadcastProjectsChange()
return { removed: true }
})
Expand Down
35 changes: 35 additions & 0 deletions src/devframe/rpc/hub-set-excluded.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { defineRpcFunction } from 'devframe'
import { getHubContext } from './utils'

/**
* Hide (or restore) an enabled project from the hub. Excluded projects stay
* loaded and keep syncing — the exclusion only filters them out of the hub
* home, aggregates, and recent/todo/queue views. Persisted to hub.json and
* broadcast so every open client refreshes its project list.
*/
export const hubSetExcluded = defineRpcFunction({
name: 'ghfs:hub-set-excluded',
type: 'action',
setup: (context) => {
const hub = getHubContext(context)
return {
handler: async (id: string, excluded: boolean): Promise<{ excluded: boolean }> => {
return hub.withLock(async () => {
const ctx = hub.projects.get(id)
if (!ctx)
return { excluded: false }
const had = hub.excluded.has(ctx.path)
if (excluded)
hub.excluded.add(ctx.path)
else
hub.excluded.delete(ctx.path)
if (had !== excluded) {
await hub.persistExcluded()
hub.broadcastProjectsChange()
}
return { excluded }
})
},
}
},
})
2 changes: 2 additions & 0 deletions src/devframe/rpc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { hubRecentItems } from './hub-recent-items'
import { hubRemoveRoot } from './hub-remove-root'
import { hubScan } from './hub-scan'
import { hubSeenHistory } from './hub-seen-history'
import { hubSetExcluded } from './hub-set-excluded'
import { hubSetSettings } from './hub-set-settings'
import { hubSettings } from './hub-settings'
import { hubTodos } from './hub-todos'
Expand Down Expand Up @@ -71,6 +72,7 @@ export const rpcFunctions = [
hubScan,
hubEnable,
hubDisable,
hubSetExcluded,
hubAddRoot,
hubRemoveRoot,
hubSettings,
Expand Down
7 changes: 5 additions & 2 deletions src/devframe/rpc/list-projects.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { defineRpcFunction } from 'devframe'
import { summarizeProject } from './helpers'
import { getProjectRegistry } from './utils'
import { getProjectRegistry, tryGetHubContext } from './utils'

export const listProjects = defineRpcFunction({
name: 'ghfs:list-projects',
type: 'query',
setup: (context) => {
const registry = getProjectRegistry(context)
const hub = tryGetHubContext(context)
return {
handler: async () => Promise.all(registry.listProjects().map(summarizeProject)),
handler: async () => Promise.all(
registry.listProjects().map(ctx => summarizeProject(ctx, { excluded: hub?.excluded.has(ctx.path) ?? false })),
),
}
},
})
8 changes: 8 additions & 0 deletions src/devframe/rpc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ export interface ProjectSummary {
lastSyncedAt?: string
/** Most recent `item.updatedAt` across the project's tracked items. */
lastActivityAt?: string
/**
* True when the user has hidden this project from the hub. Excluded projects
* stay enabled and keep syncing; the client filters them out of the hub home,
* aggregates, and recent/todo/queue views. Always `false` in single-project
* (ui) mode.
*/
excluded?: boolean
/**
* Repository label registry (names, colors, descriptions) from the most
* recent repo snapshot. Consumed by hub-level views (recent, todos) to
Expand Down Expand Up @@ -219,6 +226,7 @@ export interface GhfsServerFunctions {
'ghfs:hub-scan': () => Promise<HubScannedProject[]>
'ghfs:hub-enable': (path: string) => Promise<{ id: string }>
'ghfs:hub-disable': (id: string) => Promise<{ removed: boolean }>
'ghfs:hub-set-excluded': (id: string, excluded: boolean) => Promise<{ excluded: boolean }>
'ghfs:hub-add-root': (path: string) => Promise<HubInfo>
'ghfs:hub-remove-root': (path: string) => Promise<HubInfo>
'ghfs:hub-recent-items': (limit?: number) => Promise<HubRecentItem[]>
Expand Down
5 changes: 5 additions & 0 deletions src/devframe/rpc/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export function getHubContext(ctx: DevframeNodeContext): HubRpcContext {
return hub
}

/** Like {@link getHubContext} but returns null in single-project (ui) mode. */
export function tryGetHubContext(ctx: DevframeNodeContext): HubRpcContext | null {
return hubMap.get(ctx) ?? null
}

export function setHubContext(ctx: DevframeNodeContext, hub: HubRpcContext): void {
hubMap.set(ctx, hub)
}
Expand Down
69 changes: 69 additions & 0 deletions src/hub/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
resolveHubConfigPath,
saveHubConfig,
setEnabledProjects,
setExcludedProjects,
setHubAutoSyncInterval,
setHubCommentTemplates,
setHubSwrSettings,
Expand Down Expand Up @@ -141,6 +142,74 @@ describe('hub config', () => {
expect(config.roots).toEqual(['/a'])
})

it('round-trips excludedProjects', async () => {
const homeDir = await makeHome()
const config = await setExcludedProjects({
homeDir,
paths: ['/a/hidden-1', '/a/hidden-2', '/a/hidden-1'],
})
expect(config.excludedProjects).toEqual([
{ path: '/a/hidden-1' },
{ path: '/a/hidden-2' },
])
const reloaded = await loadHubConfig({ homeDir })
expect(reloaded.excludedProjects).toEqual([
{ path: '/a/hidden-1' },
{ path: '/a/hidden-2' },
])
})

it('setExcludedProjects preserves enabledProjects and roots', async () => {
const homeDir = await makeHome()
await saveHubConfig({
homeDir,
config: { roots: ['/a'], enabledProjects: [{ path: '/a/keep' }, { path: '/a/hide' }] },
})
const config = await setExcludedProjects({ homeDir, paths: ['/a/hide'] })
expect(config.roots).toEqual(['/a'])
expect(config.enabledProjects).toEqual([{ path: '/a/keep' }, { path: '/a/hide' }])
expect(config.excludedProjects).toEqual([{ path: '/a/hide' }])
})

it('omits excludedProjects from disk when never set', async () => {
const homeDir = await makeHome()
await saveHubConfig({ homeDir, config: { roots: ['/a'], enabledProjects: [] } })
const raw = JSON.parse(await readFile(resolveHubConfigPath({ homeDir }), 'utf8'))
expect('excludedProjects' in raw).toBe(false)
})

it('distinguishes never-set from explicitly-empty excludedProjects', async () => {
const homeDir = await makeHome()
await saveHubConfig({ homeDir, config: { roots: ['/a'], enabledProjects: [] } })
expect((await loadHubConfig({ homeDir })).excludedProjects).toBeUndefined()
await setExcludedProjects({ homeDir, paths: [] })
expect((await loadHubConfig({ homeDir })).excludedProjects).toEqual([])
})

it('removeHubRoot prunes excludedProjects under the removed root', async () => {
const homeDir = await makeHome()
await saveHubConfig({
homeDir,
config: {
roots: ['/a', '/b'],
enabledProjects: [{ path: '/a/repo-1' }, { path: '/b/repo-2' }],
excludedProjects: [{ path: '/a/repo-1' }, { path: '/b/repo-2' }],
},
})
const config = await removeHubRoot({ homeDir, path: '/a' })
expect(config.enabledProjects).toEqual([{ path: '/b/repo-2' }])
expect(config.excludedProjects).toEqual([{ path: '/b/repo-2' }])
})

it('preserves excludedProjects when other fields are mutated', async () => {
const homeDir = await makeHome()
await setExcludedProjects({ homeDir, paths: ['/a/hide'] })
await setHubAutoSyncInterval({ homeDir, intervalMs: 120_000 })
const reloaded = await loadHubConfig({ homeDir })
expect(reloaded.excludedProjects).toEqual([{ path: '/a/hide' }])
expect(reloaded.autoSyncIntervalMs).toBe(120_000)
})

it('setHubAutoSyncInterval updates the global field', async () => {
const homeDir = await makeHome()
await addHubRoot({ homeDir, path: '/a' })
Expand Down
Loading
Loading