-
Notifications
You must be signed in to change notification settings - Fork 12
Implement mobile project switching and sync catalog #172
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
8aceeb6
400e73f
c2ef038
61f7464
fb1aca4
c57db38
4b10d29
1ac5656
53ae445
dab3ff7
ed81ba0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,11 +48,12 @@ import { | |
| toProjectInfo, | ||
| upsertProjectRow, | ||
| } from "./services/projects/projectService"; | ||
| import { toRecentProjectSummary } from "./services/projects/recentProjectSummary"; | ||
| import { createAdeProjectService } from "./services/projects/adeProjectService"; | ||
| import { createConfigReloadService } from "./services/projects/configReloadService"; | ||
| import { IPC } from "../shared/ipc"; | ||
| import { resolveAdeLayout } from "../shared/adeLayout"; | ||
| import type { PortLease, ProjectInfo } from "../shared/types"; | ||
| import type { PortLease, ProjectInfo, RecentProjectSummary, SyncMobileProjectSummary, SyncProjectSwitchRequestPayload, SyncProjectSwitchResultPayload } from "../shared/types"; | ||
| import type { AppContext } from "./services/ipc/registerIpc"; | ||
| import fs from "node:fs"; | ||
| import net from "node:net"; | ||
|
|
@@ -747,7 +748,9 @@ app.whenReady().then(async () => { | |
| const closeContextPromises = new Map<string, Promise<void>>(); | ||
| const rpcSocketCleanupByRoot = new Map<string, () => void>(); | ||
| const projectLastActivatedAt = new Map<string, number>(); | ||
| const mobileSyncHandoffLeases = new Map<string, number>(); | ||
| const MAX_WARM_IDLE_PROJECT_CONTEXTS = 1; | ||
| const MOBILE_SYNC_HANDOFF_LEASE_MS = 60_000; | ||
| let activeProjectRoot: string | null = null; | ||
| let dormantContext!: AppContext; | ||
| let projectContextRebalancePromise: Promise<void> = Promise.resolve(); | ||
|
|
@@ -880,6 +883,14 @@ app.whenReady().then(async () => { | |
| } | ||
|
|
||
| try { | ||
| const leaseExpiresAt = mobileSyncHandoffLeases.get(projectRoot) ?? 0; | ||
| if (leaseExpiresAt > Date.now()) { | ||
| return true; | ||
| } | ||
| if (leaseExpiresAt > 0) { | ||
| mobileSyncHandoffLeases.delete(projectRoot); | ||
| } | ||
|
|
||
| if ((ctx.syncHostService?.getPeerStates().length ?? 0) > 0) { | ||
| return true; | ||
| } | ||
|
|
@@ -2454,6 +2465,10 @@ app.whenReady().then(async () => { | |
| processService, | ||
| hostStartupEnabled: process.env.ADE_DISABLE_SYNC_HOST !== "1", | ||
| notificationEventBus, | ||
| projectCatalogProvider: { | ||
| listProjects: listMobileSyncProjects, | ||
| prepareProjectConnection: prepareMobileSyncProjectConnection, | ||
| }, | ||
| onStatusChanged: (snapshot) => | ||
| emitProjectEvent(projectRoot, IPC.syncEvent, { | ||
| type: "sync-status", | ||
|
|
@@ -3468,6 +3483,156 @@ app.whenReady().then(async () => { | |
| setActiveProject(null); | ||
| }; | ||
|
|
||
| async function mobileProjectSummaryForContext( | ||
| ctx: AppContext, | ||
| recent?: RecentProjectSummary | null, | ||
| options: { useProjectRowId?: boolean } = {}, | ||
| ): Promise<SyncMobileProjectSummary> { | ||
| let laneCount = recent?.laneCount ?? 0; | ||
| if (!recent?.laneCount) { | ||
| try { | ||
| laneCount = (await ctx.laneService.list({ includeArchived: false })).length; | ||
| } catch { | ||
| laneCount = 0; | ||
| } | ||
| } | ||
| return { | ||
| id: options.useProjectRowId ? ctx.projectId : `root:${normalizeProjectRoot(ctx.project.rootPath)}`, | ||
| displayName: ctx.project.displayName, | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| rootPath: ctx.project.rootPath, | ||
| defaultBaseRef: ctx.project.baseRef, | ||
| lastOpenedAt: recent?.lastOpenedAt ?? null, | ||
| laneCount, | ||
| isAvailable: fs.existsSync(ctx.project.rootPath), | ||
| isCached: false, | ||
| }; | ||
| } | ||
|
|
||
| function mobileProjectSummaryForRecent(recent: RecentProjectSummary): SyncMobileProjectSummary { | ||
| const normalizedRoot = normalizeProjectRoot(recent.rootPath); | ||
| return { | ||
| id: `root:${normalizedRoot}`, | ||
| displayName: recent.displayName, | ||
| rootPath: recent.rootPath, | ||
| defaultBaseRef: null, | ||
| lastOpenedAt: recent.lastOpenedAt, | ||
| laneCount: recent.laneCount ?? 0, | ||
| isAvailable: recent.exists, | ||
| isCached: false, | ||
| }; | ||
|
arul28 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| async function listMobileSyncProjects(): Promise<{ projects: SyncMobileProjectSummary[] }> { | ||
| const recentProjects = (readGlobalState(globalStatePath).recentProjects ?? []) | ||
| .map(toRecentProjectSummary); | ||
| const recentByRoot = new Map( | ||
| recentProjects.map((entry) => [normalizeProjectRoot(entry.rootPath), entry] as const), | ||
| ); | ||
| const byRoot = new Map<string, SyncMobileProjectSummary>(); | ||
| for (const recent of recentProjects) { | ||
| byRoot.set(normalizeProjectRoot(recent.rootPath), mobileProjectSummaryForRecent(recent)); | ||
| } | ||
| for (const [root, ctx] of projectContexts) { | ||
| byRoot.set(root, await mobileProjectSummaryForContext(ctx, recentByRoot.get(root) ?? null)); | ||
| } | ||
| const projects = [...byRoot.entries()] | ||
| .sort(([leftRoot], [rightRoot]) => { | ||
| if (leftRoot === activeProjectRoot) return -1; | ||
| if (rightRoot === activeProjectRoot) return 1; | ||
| return 0; | ||
| }) | ||
| .map(([, project]) => project); | ||
| return { projects }; | ||
| } | ||
|
|
||
| async function ensureProjectContextForMobileSync(projectRoot: string): Promise<AppContext> { | ||
| const normalizedRoot = normalizeProjectRoot(projectRoot); | ||
| const existing = projectContexts.get(normalizedRoot); | ||
| if (existing) return existing; | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| if (!fs.existsSync(normalizedRoot)) { | ||
| throw new Error("Project is no longer available on this desktop."); | ||
| } | ||
|
|
||
| let initPromise = projectInitPromises.get(normalizedRoot); | ||
| if (!initPromise) { | ||
| initPromise = (async () => { | ||
| const baseRef = await detectDefaultBaseRef(normalizedRoot); | ||
| const ctx = await initContextForProjectRoot({ | ||
| projectRoot: normalizedRoot, | ||
| baseRef, | ||
| ensureExclude: true, | ||
| recordLastProject: false, | ||
| recordRecent: true, | ||
| userSelectedProject: false, | ||
| }); | ||
| projectContexts.set(normalizedRoot, ctx); | ||
| return ctx; | ||
| })().finally(() => { | ||
| projectInitPromises.delete(normalizedRoot); | ||
| }) as Promise<AppContext>; | ||
| projectInitPromises.set(normalizedRoot, initPromise); | ||
| } | ||
| return initPromise; | ||
| } | ||
|
|
||
| async function prepareMobileSyncProjectConnection( | ||
| args: SyncProjectSwitchRequestPayload, | ||
| ): Promise<SyncProjectSwitchResultPayload> { | ||
| const catalog = await listMobileSyncProjects(); | ||
| const requestedRoot = typeof args.rootPath === "string" && args.rootPath.trim() | ||
| ? normalizeProjectRoot(args.rootPath) | ||
| : null; | ||
| const requestedProjectId = typeof args.projectId === "string" && args.projectId.trim() | ||
| ? args.projectId.trim() | ||
| : null; | ||
| const catalogEntry = catalog.projects.find((entry) => { | ||
| const entryRoot = entry.rootPath ? normalizeProjectRoot(entry.rootPath) : null; | ||
| return (requestedRoot != null && entryRoot === requestedRoot) | ||
| || (requestedProjectId != null && entry.id === requestedProjectId); | ||
|
arul28 marked this conversation as resolved.
|
||
| }); | ||
| if (!catalogEntry || !catalogEntry.isAvailable) { | ||
| return { | ||
| ok: false, | ||
| message: "That project is not available from this desktop.", | ||
| }; | ||
| } | ||
| const targetRoot = catalogEntry.rootPath ? normalizeProjectRoot(catalogEntry.rootPath) : null; | ||
| if (!targetRoot) { | ||
| return { | ||
| ok: false, | ||
| message: "Choose a desktop project first.", | ||
| }; | ||
| } | ||
|
|
||
| const ctx = await ensureProjectContextForMobileSync(targetRoot); | ||
| if (!ctx.syncService) { | ||
| throw new Error("Sync is not available for that project."); | ||
| } | ||
| await ctx.syncService.initialize(); | ||
| const status = await ctx.syncService.getStatus(); | ||
| if (!status.bootstrapToken || !status.pairingConnectInfo) { | ||
| throw new Error("That project is not ready for phone sync yet."); | ||
| } | ||
| const recent = (readGlobalState(globalStatePath).recentProjects ?? []) | ||
| .map(toRecentProjectSummary) | ||
| .find((entry) => normalizeProjectRoot(entry.rootPath) === targetRoot) ?? null; | ||
| const project = await mobileProjectSummaryForContext(ctx, recent, { useProjectRowId: true }); | ||
| mobileSyncHandoffLeases.set(targetRoot, Date.now() + MOBILE_SYNC_HANDOFF_LEASE_MS); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [🟡 Medium] [🔵 Bug] This handoff lease is only consulted inside |
||
| projectLastActivatedAt.set(targetRoot, Date.now()); | ||
| scheduleProjectContextRebalance(); | ||
| return { | ||
| ok: true, | ||
| project, | ||
| connection: { | ||
| authKind: "bootstrap", | ||
| token: status.bootstrapToken, | ||
| hostIdentity: status.pairingConnectInfo.hostIdentity, | ||
| port: status.pairingConnectInfo.port, | ||
| addressCandidates: status.pairingConnectInfo.addressCandidates, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| const persistRecentProject = ( | ||
| project: ProjectInfo, | ||
| options: { recordLastProject?: boolean; recordRecent?: boolean } = {}, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.