* feat(projects): add local icons and colors * refactor(projects): simplify appearance handling * fix(projects): serialize appearance saves * test(app): update project appearance expectations * test: fix project appearance CI expectations * refactor(projects): organize appearance code * fix(server): adopt main's reconcile model in project appearance updates * fix(projects): preserve host-local appearance targets * fix(desktop): restore Linux development startup Ubuntu can block Electron's unprivileged user-namespace fallback while the npm-installed sandbox helper is not SUID. Disable the Chromium process sandbox only in the Linux development runner so packaged desktop policy remains unchanged. * feat(projects): add custom project icons Keep URL acquisition in the client so the host daemon only validates uploaded bytes. Project name and icon changes share one adaptive edit sheet. * refactor(settings): scope projects by host --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
208 lines
6.7 KiB
TypeScript
208 lines
6.7 KiB
TypeScript
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
|
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
|
|
|
export interface WorkspaceStructureHostPlacement {
|
|
serverId: string;
|
|
projectId: string;
|
|
iconWorkingDir: string;
|
|
worktreeSupport: "supported" | "unsupported" | "unknown";
|
|
customIconRevision?: string | null;
|
|
}
|
|
|
|
export interface WorkspaceStructureProject {
|
|
viewKey: string;
|
|
projectKey: string | null;
|
|
projectName: string;
|
|
projectKind: WorkspaceDescriptor["projectKind"] | "unknown";
|
|
iconWorkingDir: string;
|
|
hosts: WorkspaceStructureHostPlacement[];
|
|
workspaceKeys: string[];
|
|
}
|
|
|
|
export interface WorkspaceStructure {
|
|
projects: WorkspaceStructureProject[];
|
|
}
|
|
|
|
interface WorkspaceStructureSession {
|
|
serverId: string;
|
|
projects: Iterable<ProjectDescriptor>;
|
|
workspaces: Iterable<WorkspaceDescriptor>;
|
|
}
|
|
|
|
interface ProjectDraft {
|
|
viewKey: string;
|
|
projectKey: string | null;
|
|
projectName: string;
|
|
hasCustomName: boolean;
|
|
projectKind: WorkspaceDescriptor["projectKind"];
|
|
iconWorkingDir: string;
|
|
hosts: Map<string, WorkspaceStructureHostPlacement>;
|
|
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
|
|
}
|
|
|
|
/** The single app boundary that turns host-local projects into grouped display projects. */
|
|
export function buildWorkspaceStructureProjects(input: {
|
|
sessions: WorkspaceStructureSession[];
|
|
}): WorkspaceStructureProject[] {
|
|
const byProject = new Map<string, ProjectDraft>();
|
|
const projectEntries: Array<{ serverId: string; project: ProjectDescriptor }> = [];
|
|
const keyCountsByServer = new Map<string, Map<string, number>>();
|
|
const viewKeyByServerProjectId = new Map<string, Map<string, string>>();
|
|
|
|
for (const session of input.sessions) {
|
|
for (const project of session.projects) {
|
|
projectEntries.push({ serverId: session.serverId, project });
|
|
const sharedKey = project.projectKey ?? null;
|
|
if (sharedKey) {
|
|
const counts = getOrCreate(keyCountsByServer, session.serverId, () => new Map());
|
|
counts.set(sharedKey, (counts.get(sharedKey) ?? 0) + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
const allocatedViewKeys = new Set(
|
|
projectEntries.flatMap(({ project }) => (project.projectKey ? [project.projectKey] : [])),
|
|
);
|
|
|
|
for (const { serverId, project } of projectEntries) {
|
|
const viewKey = addProjectToView({
|
|
byProject,
|
|
keyCountsByServer,
|
|
allocatedViewKeys,
|
|
serverId,
|
|
project,
|
|
});
|
|
getOrCreate(viewKeyByServerProjectId, serverId, () => new Map()).set(
|
|
project.projectId,
|
|
viewKey,
|
|
);
|
|
}
|
|
|
|
for (const session of input.sessions) {
|
|
for (const workspace of session.workspaces) {
|
|
const viewKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId);
|
|
if (!viewKey) continue;
|
|
byProject.get(viewKey)?.workspaces.push({
|
|
workspaceId: workspace.id,
|
|
workspaceName: workspace.name,
|
|
workspaceKey: `${session.serverId}:${workspace.id}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
return Array.from(byProject.values())
|
|
.map((draft) => ({
|
|
viewKey: draft.viewKey,
|
|
projectKey: draft.projectKey,
|
|
projectName: draft.projectName,
|
|
projectKind: draft.projectKind,
|
|
iconWorkingDir: draft.iconWorkingDir,
|
|
hosts: Array.from(draft.hosts.values()),
|
|
workspaceKeys: draft.workspaces
|
|
.sort(compareWorkspaceStructureItems)
|
|
.map((workspace) => workspace.workspaceKey),
|
|
}))
|
|
.sort(
|
|
(left, right) =>
|
|
left.projectName.localeCompare(right.projectName, undefined, {
|
|
numeric: true,
|
|
sensitivity: "base",
|
|
}) || left.viewKey.localeCompare(right.viewKey),
|
|
);
|
|
}
|
|
|
|
export function createProjectViewKey(
|
|
identity:
|
|
| { kind: "equivalence"; projectKey: string }
|
|
| { kind: "placement"; serverId: string; projectId: string },
|
|
): string {
|
|
return identity.kind === "equivalence"
|
|
? identity.projectKey
|
|
: JSON.stringify([identity.serverId, identity.projectId]);
|
|
}
|
|
|
|
function allocatePlacementViewKey(
|
|
allocatedViewKeys: Set<string>,
|
|
serverId: string,
|
|
projectId: string,
|
|
): string {
|
|
const legacyKey = createProjectViewKey({ kind: "placement", serverId, projectId });
|
|
if (!allocatedViewKeys.has(legacyKey)) {
|
|
allocatedViewKeys.add(legacyKey);
|
|
return legacyKey;
|
|
}
|
|
|
|
for (let suffix = 0; ; suffix += 1) {
|
|
const collisionKey = JSON.stringify(["placement", serverId, projectId, suffix]);
|
|
if (allocatedViewKeys.has(collisionKey)) continue;
|
|
allocatedViewKeys.add(collisionKey);
|
|
return collisionKey;
|
|
}
|
|
}
|
|
|
|
function addProjectToView(input: {
|
|
byProject: Map<string, ProjectDraft>;
|
|
keyCountsByServer: Map<string, Map<string, number>>;
|
|
allocatedViewKeys: Set<string>;
|
|
serverId: string;
|
|
project: ProjectDescriptor;
|
|
}): string {
|
|
const { byProject, keyCountsByServer, serverId, project } = input;
|
|
const sharedKey = project.projectKey ?? null;
|
|
const canUseSharedKey =
|
|
sharedKey !== null && keyCountsByServer.get(serverId)?.get(sharedKey) === 1;
|
|
const viewKey = canUseSharedKey
|
|
? createProjectViewKey({ kind: "equivalence", projectKey: sharedKey })
|
|
: allocatePlacementViewKey(input.allocatedViewKeys, serverId, project.projectId);
|
|
const placement: WorkspaceStructureHostPlacement = {
|
|
serverId,
|
|
projectId: project.projectId,
|
|
iconWorkingDir: project.projectRootPath,
|
|
worktreeSupport: project.projectKind === "git" ? "supported" : "unsupported",
|
|
customIconRevision: project.projectCustomIconRevision,
|
|
};
|
|
const draft = byProject.get(viewKey);
|
|
if (!draft) {
|
|
byProject.set(viewKey, {
|
|
viewKey,
|
|
projectKey: sharedKey,
|
|
projectName:
|
|
project.projectCustomName ??
|
|
project.projectDisplayName ??
|
|
projectDisplayNameFromProjectId(project.projectId),
|
|
hasCustomName: Boolean(project.projectCustomName),
|
|
projectKind: project.projectKind,
|
|
iconWorkingDir: project.projectRootPath,
|
|
hosts: new Map([[serverId, placement]]),
|
|
workspaces: [],
|
|
});
|
|
} else {
|
|
if (project.projectCustomName && !draft.hasCustomName) {
|
|
draft.projectName = project.projectCustomName;
|
|
draft.hasCustomName = true;
|
|
}
|
|
draft.hosts.set(serverId, placement);
|
|
}
|
|
return viewKey;
|
|
}
|
|
|
|
function getOrCreate<K, V>(map: Map<K, V>, key: K, create: () => V): V {
|
|
const existing = map.get(key);
|
|
if (existing !== undefined) return existing;
|
|
const value = create();
|
|
map.set(key, value);
|
|
return value;
|
|
}
|
|
|
|
function compareWorkspaceStructureItems(
|
|
left: { workspaceId: string; workspaceName: string },
|
|
right: { workspaceId: string; workspaceName: string },
|
|
): number {
|
|
return (
|
|
left.workspaceName.localeCompare(right.workspaceName, undefined, {
|
|
numeric: true,
|
|
sensitivity: "base",
|
|
}) || left.workspaceId.localeCompare(right.workspaceId, undefined, { sensitivity: "base" })
|
|
);
|
|
}
|