fix(projects): deterministic project identity + safe per-project persistence
Derive project.id from project.path (path_<base64url(path)>), shared helper on both server (lib/projects/project-id.js) and client (lib/projectId.ts). Replace random UUIDs so icons, notes, todos, actions, setup-worktree, plans and scheduledTasks share one id across restarts and reinstalls. Fix read-then-overwrite clobber in project-config.js: scheduled-task writes now merge with the existing project json instead of replacing it, preserving client-written fields that live in the same file. On settings load migrate legacy UUID ids to canonical path ids, moving config json, storage dir contents and icon files, and remap activeProjectId. Scan for orphan non-path_* project configs and merge them into the canonical project when a \$ROOT_PROJECT_PATH/<file> reference resolves on disk, logging any that can't be matched. Client openchamberConfig.writeOpenChamberConfig re-asserts server-owned keys (version, scheduledTasks) on write to defeat the symmetric race. Stores and persistence derive ids from path consistently.
This commit is contained in:
@@ -7,41 +7,14 @@
|
||||
import type { FilesAPI, RuntimeAPIs } from './api/types';
|
||||
import { getDesktopHomeDirectory } from './desktop';
|
||||
import { isVSCodeRuntime } from './desktop';
|
||||
import { createProjectIdFromPath } from './projectId';
|
||||
|
||||
type ProjectRef = { id: string; path: string };
|
||||
|
||||
const CONFIG_FILENAME = 'openchamber.json';
|
||||
// LEGACY_PROJECT_CONFIG: legacy per-project config root inside repo.
|
||||
const LEGACY_CONFIG_DIR = '.openchamber';
|
||||
const USER_CONFIG_DIR_SEGMENTS = ['.config', 'openchamber'];
|
||||
const USER_PROJECTS_DIR_SEGMENTS = ['.config', 'openchamber', 'projects'];
|
||||
const SETTINGS_FILENAME = 'settings.json';
|
||||
|
||||
const projectIdCache = new Map<string, string>();
|
||||
|
||||
const isSafeConfigFileId = (value: string): boolean => /^[A-Za-z0-9._-]+$/.test(value);
|
||||
|
||||
const toHex = (bytes: Uint8Array): string => {
|
||||
let out = '';
|
||||
for (const b of bytes) {
|
||||
out += b.toString(16).padStart(2, '0');
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const sha1Hex = async (value: string): Promise<string | null> => {
|
||||
try {
|
||||
if (typeof crypto === 'undefined' || !crypto.subtle) {
|
||||
return null;
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(value);
|
||||
const digest = await crypto.subtle.digest('SHA-1', data);
|
||||
return toHex(new Uint8Array(digest));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the runtime Files API if available (Desktop/VSCode).
|
||||
@@ -56,9 +29,11 @@ function getRuntimeFilesAPI(): FilesAPI | null {
|
||||
}
|
||||
|
||||
export interface OpenChamberConfig {
|
||||
projectPath?: string;
|
||||
'setup-worktree'?: string[];
|
||||
projectNotes?: string;
|
||||
projectTodos?: OpenChamberProjectTodoItem[];
|
||||
projectPlanFiles?: OpenChamberProjectPlanFileLink[];
|
||||
projectActions?: OpenChamberProjectAction[];
|
||||
projectActionsPrimaryId?: string;
|
||||
}
|
||||
@@ -88,17 +63,35 @@ export interface OpenChamberProjectTodoItem {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface OpenChamberProjectPlanFileLink {
|
||||
id: string;
|
||||
path: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface OpenChamberProjectPlanFile {
|
||||
title: string;
|
||||
body: string;
|
||||
raw: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface OpenChamberProjectNotesTodos {
|
||||
notes: string;
|
||||
todos: OpenChamberProjectTodoItem[];
|
||||
}
|
||||
|
||||
export interface OpenChamberProjectContextData extends OpenChamberProjectNotesTodos {
|
||||
plans: OpenChamberProjectPlanFileLink[];
|
||||
}
|
||||
|
||||
export const OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH = 1000;
|
||||
export const OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH = 120;
|
||||
export const OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH = 80;
|
||||
export const OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH = 4000;
|
||||
export const OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH = 2000;
|
||||
export const OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH = 300;
|
||||
export const OPENCHAMBER_PROJECT_PLAN_TITLE_MAX_LENGTH = 160;
|
||||
|
||||
const OPENCHAMBER_ACTION_PLATFORM_SET = new Set<OpenChamberProjectActionPlatform>(['macos', 'linux', 'windows']);
|
||||
|
||||
@@ -226,14 +219,6 @@ const resolveHomeDirectory = async (): Promise<string | null> => {
|
||||
}
|
||||
};
|
||||
|
||||
const getUserConfigRootDirectory = async (): Promise<string | null> => {
|
||||
const home = await resolveHomeDirectory();
|
||||
if (!home) {
|
||||
return null;
|
||||
}
|
||||
return USER_CONFIG_DIR_SEGMENTS.reduce((acc, segment) => joinPath(acc, segment), home);
|
||||
};
|
||||
|
||||
const getUserProjectsDirectory = async (): Promise<string | null> => {
|
||||
const home = await resolveHomeDirectory();
|
||||
if (!home) {
|
||||
@@ -242,63 +227,11 @@ const getUserProjectsDirectory = async (): Promise<string | null> => {
|
||||
return USER_PROJECTS_DIR_SEGMENTS.reduce((acc, segment) => joinPath(acc, segment), home);
|
||||
};
|
||||
|
||||
const getSettingsPath = async (): Promise<string | null> => {
|
||||
const base = await getUserConfigRootDirectory();
|
||||
if (!base) {
|
||||
return null;
|
||||
}
|
||||
return joinPath(base, SETTINGS_FILENAME);
|
||||
};
|
||||
|
||||
const resolveConfigProjectId = async (project: ProjectRef): Promise<string | null> => {
|
||||
const resolveConfigProjectId = (project: ProjectRef): string | null => {
|
||||
const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : '';
|
||||
const normalizedProject = projectDirectory ? normalize(projectDirectory) : '';
|
||||
|
||||
const explicitId = typeof project?.id === 'string' ? project.id.trim() : '';
|
||||
if (explicitId && isSafeConfigFileId(explicitId)) {
|
||||
return explicitId;
|
||||
}
|
||||
|
||||
if (normalizedProject) {
|
||||
const cached = projectIdCache.get(normalizedProject);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort map project directory -> persisted project id from settings.json.
|
||||
const settingsPath = await getSettingsPath();
|
||||
if (settingsPath && normalizedProject) {
|
||||
const raw = await readTextFile(settingsPath);
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { projects?: unknown };
|
||||
const projects = Array.isArray(parsed?.projects) ? parsed.projects : [];
|
||||
for (const entry of projects) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const record = entry as { id?: unknown; path?: unknown };
|
||||
const id = typeof record.id === 'string' ? record.id.trim() : '';
|
||||
const path = typeof record.path === 'string' ? normalize(record.path.trim()) : '';
|
||||
if (id && isSafeConfigFileId(id) && path && path === normalizedProject) {
|
||||
projectIdCache.set(normalizedProject, id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: stable id derived from path (used in VSCode when project isn't registered).
|
||||
if (normalizedProject) {
|
||||
const digest = await sha1Hex(normalizedProject);
|
||||
const fallback = digest ? `path_${digest}` : `path_${normalizedProject.replace(/[^A-Za-z0-9._-]+/g, '_')}`;
|
||||
projectIdCache.set(normalizedProject, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return null;
|
||||
if (!normalizedProject) return null;
|
||||
return createProjectIdFromPath(normalizedProject) || null;
|
||||
};
|
||||
|
||||
const getUserConfigPath = async (project: ProjectRef): Promise<string | null> => {
|
||||
@@ -306,7 +239,7 @@ const getUserConfigPath = async (project: ProjectRef): Promise<string | null> =>
|
||||
if (!base) {
|
||||
return null;
|
||||
}
|
||||
const safeId = await resolveConfigProjectId(project);
|
||||
const safeId = resolveConfigProjectId(project);
|
||||
if (!safeId) {
|
||||
return null;
|
||||
}
|
||||
@@ -370,6 +303,43 @@ const sanitizeProjectTodoItems = (value: unknown): OpenChamberProjectTodoItem[]
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
const sanitizeProjectPlanFileLinks = (value: unknown): OpenChamberProjectPlanFileLink[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sanitized: OpenChamberProjectPlanFileLink[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = entry as {
|
||||
id?: unknown;
|
||||
path?: unknown;
|
||||
createdAt?: unknown;
|
||||
};
|
||||
|
||||
const id = typeof record.id === 'string' ? record.id.trim() : '';
|
||||
const path = typeof record.path === 'string' ? record.path.trim() : '';
|
||||
const createdAt =
|
||||
typeof record.createdAt === 'number' && Number.isFinite(record.createdAt) && record.createdAt >= 0
|
||||
? record.createdAt
|
||||
: Date.now();
|
||||
|
||||
if (!id || !path || seenIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
sanitized.push({ id, path, createdAt });
|
||||
}
|
||||
|
||||
return sanitized.sort((a, b) => b.createdAt - a.createdAt);
|
||||
};
|
||||
|
||||
const sanitizeProjectActionPlatforms = (value: unknown): OpenChamberProjectActionPlatform[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
@@ -479,6 +449,87 @@ const sanitizeProjectNotesAndTodos = (value: {
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizeProjectContextData = (value: {
|
||||
notes?: unknown;
|
||||
todos?: unknown;
|
||||
plans?: unknown;
|
||||
} | null | undefined): OpenChamberProjectContextData => {
|
||||
const notesAndTodos = sanitizeProjectNotesAndTodos(value);
|
||||
return {
|
||||
...notesAndTodos,
|
||||
plans: sanitizeProjectPlanFileLinks(value?.plans),
|
||||
};
|
||||
};
|
||||
|
||||
const slugifyPlanTitle = (value: string): string => {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[`*_#>[\](){}.!?,:;"']/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return normalized || 'plan';
|
||||
};
|
||||
|
||||
const sanitizePlanTitle = (value: string): string => {
|
||||
return trimToMaxLength(value.trim(), OPENCHAMBER_PROJECT_PLAN_TITLE_MAX_LENGTH);
|
||||
};
|
||||
|
||||
const createProjectPlanId = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `plan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
};
|
||||
|
||||
const getProjectStorageDirectory = async (project: ProjectRef): Promise<string | null> => {
|
||||
const base = await getUserProjectsDirectory();
|
||||
const safeId = resolveConfigProjectId(project);
|
||||
if (!base || !safeId) {
|
||||
return null;
|
||||
}
|
||||
return joinPath(base, safeId);
|
||||
};
|
||||
|
||||
const getProjectPlansDirectory = async (project: ProjectRef): Promise<string | null> => {
|
||||
const projectDirectory = await getProjectStorageDirectory(project);
|
||||
if (!projectDirectory) {
|
||||
return null;
|
||||
}
|
||||
return joinPath(projectDirectory, 'plans');
|
||||
};
|
||||
|
||||
export const formatProjectPlanMarkdown = (title: string, body: string): string => {
|
||||
const normalizedTitle = sanitizePlanTitle(title) || 'Plan';
|
||||
const normalizedBody = body.trim();
|
||||
return normalizedBody
|
||||
? `# ${normalizedTitle}\n\n${normalizedBody}`
|
||||
: `# ${normalizedTitle}\n`;
|
||||
};
|
||||
|
||||
export const parseProjectPlanMarkdown = (raw: string): { title: string; body: string } => {
|
||||
const text = typeof raw === 'string' ? raw : '';
|
||||
const normalized = text.replace(/\r\n?/g, '\n');
|
||||
const match = normalized.match(/^\s*#\s+(.+?)\s*(?:\n+|$)/);
|
||||
if (match) {
|
||||
const title = sanitizePlanTitle(match[1]);
|
||||
const body = normalized.slice(match[0].length).replace(/^\n+/, '');
|
||||
return {
|
||||
title: title || 'Plan',
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
const firstNonEmptyLine = normalized.split('\n').map((line) => line.trim()).find(Boolean) || 'Plan';
|
||||
return {
|
||||
title: sanitizePlanTitle(firstNonEmptyLine.replace(/^#+\s*/, '')) || 'Plan',
|
||||
body: normalized.trim(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the config for a project.
|
||||
* Returns null if file doesn't exist or is invalid.
|
||||
@@ -550,6 +601,10 @@ export async function readOpenChamberConfig(project: ProjectRef): Promise<OpenCh
|
||||
|
||||
/**
|
||||
* Write the per-user config for a project.
|
||||
*
|
||||
* Server owns `version` and `scheduledTasks` keys; client reads them via their
|
||||
* dedicated route and never round-trips them through this config write path to
|
||||
* avoid a read-then-write race clobbering a concurrent server update.
|
||||
*/
|
||||
export async function writeOpenChamberConfig(
|
||||
project: ProjectRef,
|
||||
@@ -567,13 +622,34 @@ export async function writeOpenChamberConfig(
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure user config directory exists.
|
||||
const okDir = await mkdirp(configDir);
|
||||
if (!okDir) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = JSON.stringify(config, null, 2);
|
||||
const existingRaw = await readTextFile(configPath);
|
||||
let existing: Record<string, unknown> = {};
|
||||
if (typeof existingRaw === 'string' && existingRaw.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(existingRaw);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
existing = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
}
|
||||
|
||||
const serverOwned: Record<string, unknown> = {};
|
||||
if (existing.version !== undefined) serverOwned.version = existing.version;
|
||||
if (existing.scheduledTasks !== undefined) serverOwned.scheduledTasks = existing.scheduledTasks;
|
||||
|
||||
const content = JSON.stringify({
|
||||
...existing,
|
||||
...config,
|
||||
...serverOwned,
|
||||
projectPath: normalize(projectDirectory),
|
||||
}, null, 2);
|
||||
return await writeTextFile(configPath, content);
|
||||
} catch (error) {
|
||||
console.error('Failed to write openchamber config:', error);
|
||||
@@ -629,6 +705,90 @@ export async function saveProjectNotesAndTodos(
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProjectContextData(project: ProjectRef): Promise<OpenChamberProjectContextData> {
|
||||
const config = await readOpenChamberConfig(project);
|
||||
return sanitizeProjectContextData({
|
||||
notes: config?.projectNotes,
|
||||
todos: config?.projectTodos,
|
||||
plans: config?.projectPlanFiles,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProjectPlanFiles(project: ProjectRef): Promise<OpenChamberProjectPlanFileLink[]> {
|
||||
const config = await readOpenChamberConfig(project);
|
||||
return sanitizeProjectPlanFileLinks(config?.projectPlanFiles);
|
||||
}
|
||||
|
||||
export async function saveProjectPlanFiles(
|
||||
project: ProjectRef,
|
||||
value: OpenChamberProjectPlanFileLink[]
|
||||
): Promise<boolean> {
|
||||
const sanitized = sanitizeProjectPlanFileLinks(value);
|
||||
return updateOpenChamberConfig(project, {
|
||||
projectPlanFiles: sanitized,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readProjectPlanFile(path: string): Promise<OpenChamberProjectPlanFile | null> {
|
||||
const trimmedPath = typeof path === 'string' ? path.trim() : '';
|
||||
if (!trimmedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = await readTextFile(trimmedPath);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseProjectPlanMarkdown(raw);
|
||||
return {
|
||||
title: parsed.title,
|
||||
body: parsed.body,
|
||||
raw,
|
||||
path: trimmedPath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createProjectPlanFile(
|
||||
project: ProjectRef,
|
||||
value: { title: string; body: string }
|
||||
): Promise<OpenChamberProjectPlanFileLink | null> {
|
||||
const plansDirectory = await getProjectPlansDirectory(project);
|
||||
if (!plansDirectory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = sanitizePlanTitle(value.title) || 'Plan';
|
||||
const createdAt = Date.now();
|
||||
const id = createProjectPlanId();
|
||||
const filePath = joinPath(plansDirectory, `${createdAt}-${slugifyPlanTitle(title)}.md`);
|
||||
|
||||
const projectDirectory = await getProjectStorageDirectory(project);
|
||||
if (!projectDirectory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createdProjectDir = await mkdirp(projectDirectory);
|
||||
const createdPlansDir = createdProjectDir ? await mkdirp(plansDirectory) : false;
|
||||
if (!createdProjectDir || !createdPlansDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wrote = await writeTextFile(filePath, formatProjectPlanMarkdown(title, value.body));
|
||||
if (!wrote) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = await getProjectPlanFiles(project);
|
||||
const nextEntry = { id, path: filePath, createdAt };
|
||||
const saved = await saveProjectPlanFiles(project, [nextEntry, ...existing]);
|
||||
if (!saved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return nextEntry;
|
||||
}
|
||||
|
||||
export async function getProjectActionsState(project: ProjectRef): Promise<OpenChamberProjectActionsState> {
|
||||
const config = await readOpenChamberConfig(project);
|
||||
return sanitizeProjectActionsState({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DesktopSettings } from '@/lib/desktop';
|
||||
import { createProjectIdFromPath } from '@/lib/projectId';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
import { setDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||
@@ -149,13 +150,15 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const candidate = entry as Record<string, unknown>;
|
||||
|
||||
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
|
||||
if (!id || !rawPath) continue;
|
||||
if (!rawPath) continue;
|
||||
|
||||
const normalizedPath = rawPath === '/' ? rawPath : rawPath.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
if (!normalizedPath) continue;
|
||||
|
||||
const id = createProjectIdFromPath(normalizedPath);
|
||||
if (!id) continue;
|
||||
|
||||
if (seenIds.has(id) || seenPaths.has(normalizedPath)) continue;
|
||||
seenIds.add(id);
|
||||
seenPaths.add(normalizedPath);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export const createProjectIdFromPath = (projectPath: string): string => {
|
||||
const normalized = projectPath.replace(/\\/g, '/').replace(/\/+$/g, '').trim();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const data = new TextEncoder().encode(normalized);
|
||||
let binary = '';
|
||||
for (const byte of data) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
|
||||
const encoded = typeof btoa === 'function'
|
||||
? btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
: normalized.replace(/[^A-Za-z0-9._-]+/g, '_');
|
||||
|
||||
return `path_${encoded}`;
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import type { DesktopSettings } from '@/lib/desktop';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { createProjectIdFromPath } from '@/lib/projectId';
|
||||
import { getSafeStorage } from './utils/safeStorage';
|
||||
import { useDirectoryStore } from './useDirectoryStore';
|
||||
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
||||
@@ -112,13 +113,6 @@ const deriveProjectLabel = (path: string): string => {
|
||||
return raw.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
};
|
||||
|
||||
const createProjectId = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `proj_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
};
|
||||
|
||||
const sanitizeProjectIconImage = (value: unknown): ProjectEntry['iconImage'] | undefined => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
@@ -185,13 +179,15 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const candidate = entry as Record<string, unknown>;
|
||||
|
||||
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
|
||||
if (!id || !rawPath) continue;
|
||||
if (!rawPath) continue;
|
||||
|
||||
const normalizedPath = normalizeProjectPath(rawPath);
|
||||
if (!normalizedPath) continue;
|
||||
|
||||
const id = createProjectIdFromPath(normalizedPath);
|
||||
if (!id) continue;
|
||||
|
||||
if (seenIds.has(id) || seenPaths.has(normalizedPath)) continue;
|
||||
seenIds.add(id);
|
||||
seenPaths.add(normalizedPath);
|
||||
@@ -310,7 +306,7 @@ const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectI
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = `vscode:${normalizedPath}`;
|
||||
const id = createProjectIdFromPath(normalizedPath);
|
||||
const entry: ProjectEntry = {
|
||||
id,
|
||||
path: normalizedPath,
|
||||
@@ -330,10 +326,10 @@ const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectI
|
||||
// Always prefer the workspace project over any persisted multi-project registry.
|
||||
const vscodeWorkspace = getVSCodeWorkspaceProject();
|
||||
const effectiveInitialProjects = vscodeWorkspace?.projects ?? initialProjects;
|
||||
const initialActiveProjectId = vscodeWorkspace?.activeProjectId
|
||||
?? readPersistedActiveProjectId()
|
||||
?? effectiveInitialProjects[0]?.id
|
||||
?? null;
|
||||
const persistedInitialActiveProjectId = vscodeWorkspace?.activeProjectId ?? readPersistedActiveProjectId();
|
||||
const initialActiveProjectId = effectiveInitialProjects.some((project) => project.id === persistedInitialActiveProjectId)
|
||||
? persistedInitialActiveProjectId
|
||||
: effectiveInitialProjects[0]?.id ?? null;
|
||||
|
||||
if (vscodeWorkspace) {
|
||||
cacheProjects(effectiveInitialProjects, initialActiveProjectId);
|
||||
@@ -376,10 +372,7 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
|
||||
const now = Date.now();
|
||||
const label = options?.label?.trim() || deriveProjectLabel(normalizedPath);
|
||||
const candidateId = options?.id?.trim();
|
||||
const id = candidateId && !get().projects.some((project) => project.id === candidateId)
|
||||
? candidateId
|
||||
: createProjectId();
|
||||
const id = createProjectIdFromPath(normalizedPath);
|
||||
const entry: ProjectEntry = {
|
||||
id,
|
||||
path: normalizedPath,
|
||||
|
||||
Reference in New Issue
Block a user