mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Add configurable project locations (#2041)
* add daemon project location support * wire project locations into web settings * localize project location settings * move default project location to settings * polish project location selection cards * fix project location i18n gaps * fix external project validation cleanup
This commit is contained in:
parent
3395d2c855
commit
af4a62b69a
40 changed files with 2143 additions and 44 deletions
|
|
@ -13,8 +13,9 @@
|
||||||
// outside this machine.
|
// outside this machine.
|
||||||
|
|
||||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { expandHomePrefix } from './home-expansion.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
readInstallationFile,
|
readInstallationFile,
|
||||||
|
|
@ -85,6 +86,12 @@ export interface OrbitConfigPrefs {
|
||||||
templateSkillId?: string | null;
|
templateSkillId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectLocationPrefs {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppConfigPrefs {
|
export interface AppConfigPrefs {
|
||||||
onboardingCompleted?: boolean;
|
onboardingCompleted?: boolean;
|
||||||
agentId?: string | null;
|
agentId?: string | null;
|
||||||
|
|
@ -99,6 +106,8 @@ export interface AppConfigPrefs {
|
||||||
privacyDecisionAt?: number | null;
|
privacyDecisionAt?: number | null;
|
||||||
orbit?: OrbitConfigPrefs;
|
orbit?: OrbitConfigPrefs;
|
||||||
customInstructions?: string | null;
|
customInstructions?: string | null;
|
||||||
|
projectLocations?: ProjectLocationPrefs[];
|
||||||
|
defaultProjectLocationId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = new Set([
|
const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = new Set([
|
||||||
|
|
@ -115,6 +124,8 @@ const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = new Set([
|
||||||
'privacyDecisionAt',
|
'privacyDecisionAt',
|
||||||
'orbit',
|
'orbit',
|
||||||
'customInstructions',
|
'customInstructions',
|
||||||
|
'projectLocations',
|
||||||
|
'defaultProjectLocationId',
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
function configFile(dataDir: string): string {
|
function configFile(dataDir: string): string {
|
||||||
|
|
@ -245,6 +256,46 @@ function validateOrbit(raw: unknown): OrbitConfigPrefs | undefined {
|
||||||
return orbit;
|
return orbit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLocationId(raw: string, fallback: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (/^[A-Za-z0-9._-]{1,128}$/.test(trimmed) && trimmed !== 'default') {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoProjectLocationId(pathKey: string): string {
|
||||||
|
return `loc_${createHash('sha256').update(pathKey).digest('base64url').slice(0, 16)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateProjectLocations(raw: unknown): ProjectLocationPrefs[] | undefined {
|
||||||
|
if (raw === undefined || raw === null) return undefined;
|
||||||
|
if (!Array.isArray(raw)) return undefined;
|
||||||
|
const result: ProjectLocationPrefs[] = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const seenPaths = new Set<string>();
|
||||||
|
for (const item of raw) {
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
|
||||||
|
const obj = item as Record<string, unknown>;
|
||||||
|
if (typeof obj.path !== 'string') continue;
|
||||||
|
const expanded = expandHomePrefix(obj.path.trim());
|
||||||
|
if (!expanded || !path.isAbsolute(expanded)) continue;
|
||||||
|
const normalizedPath = path.normalize(expanded);
|
||||||
|
const pathKey = process.platform === 'win32' ? normalizedPath.toLowerCase() : normalizedPath;
|
||||||
|
if (seenPaths.has(pathKey)) continue;
|
||||||
|
const id = normalizeLocationId(
|
||||||
|
typeof obj.id === 'string' ? obj.id : '',
|
||||||
|
autoProjectLocationId(pathKey),
|
||||||
|
);
|
||||||
|
if (seenIds.has(id)) continue;
|
||||||
|
const rawName = typeof obj.name === 'string' ? obj.name.trim() : '';
|
||||||
|
result.push({ id, name: rawName || path.basename(normalizedPath) || normalizedPath, path: normalizedPath });
|
||||||
|
seenIds.add(id);
|
||||||
|
seenPaths.add(pathKey);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function agentCliEnvForAgent(
|
export function agentCliEnvForAgent(
|
||||||
prefs: AgentCliEnvPrefs | undefined,
|
prefs: AgentCliEnvPrefs | undefined,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
|
|
@ -330,6 +381,25 @@ function applyConfigValue(
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (key === 'projectLocations') {
|
||||||
|
const validated = validateProjectLocations(value);
|
||||||
|
if (validated !== undefined) {
|
||||||
|
target[key] = validated;
|
||||||
|
} else {
|
||||||
|
delete target[key];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'defaultProjectLocationId') {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
target[key] = normalizeLocationId(value, 'default');
|
||||||
|
} else if (value === null) {
|
||||||
|
target[key] = null;
|
||||||
|
} else {
|
||||||
|
delete target[key];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterAllowedKeys(obj: Record<string, unknown>): AppConfigPrefs {
|
function filterAllowedKeys(obj: Record<string, unknown>): AppConfigPrefs {
|
||||||
|
|
|
||||||
130
apps/daemon/src/project-locations.ts
Normal file
130
apps/daemon/src/project-locations.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { lstat, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { ProjectLocationPrefs } from './app-config.js';
|
||||||
|
import { expandHomePrefix } from './home-expansion.js';
|
||||||
|
import { isSafeId } from './projects.js';
|
||||||
|
|
||||||
|
export const BUILT_IN_PROJECT_LOCATION_ID = 'default';
|
||||||
|
export const PROJECT_MANIFEST_RELATIVE_PATH = path.join('.open-design', 'project.json');
|
||||||
|
|
||||||
|
export interface ProjectLocation extends ProjectLocationPrefs {
|
||||||
|
builtIn?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectManifest {
|
||||||
|
schemaVersion: 1;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
skillId?: string | null;
|
||||||
|
designSystemId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function builtInProjectLocation(projectsDir: string): ProjectLocation {
|
||||||
|
return {
|
||||||
|
id: BUILT_IN_PROJECT_LOCATION_ID,
|
||||||
|
name: 'Open Design projects',
|
||||||
|
path: projectsDir,
|
||||||
|
builtIn: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allProjectLocations(projectsDir: string, external: ProjectLocationPrefs[] | undefined): ProjectLocation[] {
|
||||||
|
return [builtInProjectLocation(projectsDir), ...(external ?? [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function locationProjectDir(location: ProjectLocation, projectId: string): string {
|
||||||
|
if (!isSafeId(projectId)) throw new Error('invalid project id');
|
||||||
|
return path.join(location.path, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertInsideLocation(locationRoot: string, projectDir: string): void {
|
||||||
|
const relative = path.relative(locationRoot, projectDir);
|
||||||
|
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
|
throw new Error('project directory escapes project location');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLocationProjectDir(location: ProjectLocation, projectId: string): Promise<string> {
|
||||||
|
const root = await realpath(location.path);
|
||||||
|
const target = locationProjectDir({ ...location, path: root }, projectId);
|
||||||
|
await mkdir(target, { recursive: false });
|
||||||
|
const info = await lstat(target);
|
||||||
|
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('project directory must be a real directory');
|
||||||
|
const canonical = await realpath(target);
|
||||||
|
assertInsideLocation(root, canonical);
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function canonicalLocationChildDir(location: ProjectLocation, childName: string): Promise<string> {
|
||||||
|
const root = await realpath(location.path);
|
||||||
|
if (!isSafeId(childName)) throw new Error('invalid project directory name');
|
||||||
|
const target = path.join(root, childName);
|
||||||
|
const info = await lstat(target);
|
||||||
|
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('project directory must be a real directory');
|
||||||
|
const canonical = await realpath(target);
|
||||||
|
assertInsideLocation(root, canonical);
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function manifestPath(projectDir: string): string {
|
||||||
|
return path.join(projectDir, PROJECT_MANIFEST_RELATIVE_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureProjectLocation(locationPath: string): Promise<string> {
|
||||||
|
const expanded = expandHomePrefix(locationPath.trim());
|
||||||
|
if (!path.isAbsolute(expanded)) throw new Error(`project location must be an absolute path: ${locationPath}`);
|
||||||
|
await mkdir(expanded, { recursive: true });
|
||||||
|
return realpath(expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeProjectManifest(projectDir: string, manifest: ProjectManifest): Promise<void> {
|
||||||
|
const file = manifestPath(projectDir);
|
||||||
|
await mkdir(path.dirname(file), { recursive: true });
|
||||||
|
await writeFile(file, JSON.stringify(manifest, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readProjectManifest(projectDir: string): Promise<ProjectManifest | null> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(manifestPath(projectDir), 'utf8');
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
if (obj.schemaVersion !== 1) return null;
|
||||||
|
if (typeof obj.id !== 'string' || !isSafeId(obj.id)) return null;
|
||||||
|
if (typeof obj.name !== 'string' || !obj.name.trim()) return null;
|
||||||
|
const createdAt = typeof obj.createdAt === 'number' && Number.isFinite(obj.createdAt) ? obj.createdAt : Date.now();
|
||||||
|
const updatedAt = typeof obj.updatedAt === 'number' && Number.isFinite(obj.updatedAt) ? obj.updatedAt : createdAt;
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
id: obj.id,
|
||||||
|
name: obj.name.trim(),
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
skillId: typeof obj.skillId === 'string' ? obj.skillId : null,
|
||||||
|
designSystemId: typeof obj.designSystemId === 'string' ? obj.designSystemId : null,
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { code?: string; name?: string };
|
||||||
|
if (e.code === 'ENOENT' || e.name === 'SyntaxError') return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanProjectLocation(location: ProjectLocation): Promise<Array<{ dir: string; manifest: ProjectManifest }>> {
|
||||||
|
const entries = await readdir(location.path, { withFileTypes: true });
|
||||||
|
const found: Array<{ dir: string; manifest: ProjectManifest }> = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
let dir: string;
|
||||||
|
try {
|
||||||
|
dir = await canonicalLocationChildDir(location, entry.name);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const manifest = await readProjectManifest(dir);
|
||||||
|
if (manifest) found.push({ dir, manifest });
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Express } from 'express';
|
import type { Express } from 'express';
|
||||||
|
import { rm } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
defaultScenarioPluginIdForProjectMetadata,
|
defaultScenarioPluginIdForProjectMetadata,
|
||||||
|
|
@ -18,9 +19,18 @@ import {
|
||||||
import { connectorService } from './connectors/service.js';
|
import { connectorService } from './connectors/service.js';
|
||||||
import type { RouteDeps } from './server-context.js';
|
import type { RouteDeps } from './server-context.js';
|
||||||
import { listSkills } from './skills.js';
|
import { listSkills } from './skills.js';
|
||||||
|
import { isSafeId } from './projects.js';
|
||||||
|
import {
|
||||||
|
BUILT_IN_PROJECT_LOCATION_ID,
|
||||||
|
allProjectLocations,
|
||||||
|
createLocationProjectDir,
|
||||||
|
ensureProjectLocation,
|
||||||
|
scanProjectLocation,
|
||||||
|
writeProjectManifest,
|
||||||
|
} from './project-locations.js';
|
||||||
import { auditDesignSystemPackage } from './tools-connectors-cli.js';
|
import { auditDesignSystemPackage } from './tools-connectors-cli.js';
|
||||||
|
|
||||||
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'validation'> {}
|
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'appConfig' | 'validation'> {}
|
||||||
|
|
||||||
function projectDetailResolvedDir(
|
function projectDetailResolvedDir(
|
||||||
projectsRoot: string,
|
projectsRoot: string,
|
||||||
|
|
@ -145,6 +155,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
const { db, design } = ctx;
|
const { db, design } = ctx;
|
||||||
const { sendApiError, createSseResponse } = ctx.http;
|
const { sendApiError, createSseResponse } = ctx.http;
|
||||||
const { DESIGN_SYSTEMS_DIR, PROJECTS_DIR, SKILLS_DIR } = ctx.paths;
|
const { DESIGN_SYSTEMS_DIR, PROJECTS_DIR, SKILLS_DIR } = ctx.paths;
|
||||||
|
const { readAppConfig, writeAppConfig } = ctx.appConfig;
|
||||||
const { insertProject, validateLinkedDirs, getProject, updateProject, dbDeleteProject, removeProjectDir } = ctx.projectStore;
|
const { insertProject, validateLinkedDirs, getProject, updateProject, dbDeleteProject, removeProjectDir } = ctx.projectStore;
|
||||||
const { writeProjectFile, readProjectFile, ensureProject, listFiles, listTabs, setTabs, resolveProjectDir } = ctx.projectFiles;
|
const { writeProjectFile, readProjectFile, ensureProject, listFiles, listTabs, setTabs, resolveProjectDir } = ctx.projectFiles;
|
||||||
const { insertConversation, getConversation, listConversations, updateConversation, deleteConversation, listMessages, upsertMessage, listPreviewComments, upsertPreviewComment, updatePreviewCommentStatus, deletePreviewComment } = ctx.conversations;
|
const { insertConversation, getConversation, listConversations, updateConversation, deleteConversation, listMessages, upsertMessage, listPreviewComments, upsertPreviewComment, updatePreviewCommentStatus, deletePreviewComment } = ctx.conversations;
|
||||||
|
|
@ -202,8 +213,199 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
return Array.from(byTaskKind.values());
|
return Array.from(byTaskKind.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/api/projects', (_req, res) => {
|
async function configuredProjectLocations() {
|
||||||
|
const config = await readAppConfig(ctx.paths.RUNTIME_DATA_DIR);
|
||||||
|
const all = allProjectLocations(PROJECTS_DIR, config.projectLocations);
|
||||||
|
const valid = all[0] ? [all[0]] : [];
|
||||||
|
for (const location of all.slice(1)) {
|
||||||
|
const validated = validateLinkedDirs([location.path]);
|
||||||
|
if (validated.error) continue;
|
||||||
|
const canonical = validated.dirs[0];
|
||||||
|
if (!canonical) continue;
|
||||||
|
if (locationOverlapsDaemonData(canonical)) continue;
|
||||||
|
valid.push({ ...location, path: canonical });
|
||||||
|
}
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationOverlapsDaemonData(locationPath: string): boolean {
|
||||||
|
const runtimeDir = ctx.paths.RUNTIME_DATA_DIR_CANONICAL || ctx.paths.RUNTIME_DATA_DIR;
|
||||||
|
const projectsDir = path.join(runtimeDir, 'projects');
|
||||||
|
const relativeToRuntime = pathRelative(runtimeDir, locationPath);
|
||||||
|
const runtimeInsideLocation = pathRelative(locationPath, runtimeDir);
|
||||||
|
const relativeToProjects = pathRelative(projectsDir, locationPath);
|
||||||
|
const projectsInsideLocation = pathRelative(locationPath, projectsDir);
|
||||||
|
return isInsideOrSame(relativeToRuntime) || isInsideOrSame(runtimeInsideLocation)
|
||||||
|
|| isInsideOrSame(relativeToProjects) || isInsideOrSame(projectsInsideLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathRelative(from: string, to: string): string {
|
||||||
|
return path.relative(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideOrSame(relative: string): boolean {
|
||||||
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectBelongsToLocation(project: any, location: { id: string; path: string }): boolean {
|
||||||
|
const metadata = project?.metadata;
|
||||||
|
if (typeof metadata?.baseDir !== 'string') return metadata?.projectLocationId === location.id;
|
||||||
|
const relative = path.relative(location.path, metadata.baseDir);
|
||||||
|
return isInsideOrSame(relative) && relative !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProjectLocationProject(project: any): boolean {
|
||||||
|
const metadata = project?.metadata;
|
||||||
|
return metadata?.importedFrom === 'project-location'
|
||||||
|
|| typeof metadata?.projectLocationId === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectVisibleForLocations(
|
||||||
|
project: any,
|
||||||
|
locations: Array<{ id: string; path: string; builtIn?: boolean }>,
|
||||||
|
): boolean {
|
||||||
|
if (!isProjectLocationProject(project)) return true;
|
||||||
|
return locations.some((location) => !location.builtIn && projectBelongsToLocation(project, location));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCreateProjectLocationId(explicitProjectLocationId: unknown): Promise<string> {
|
||||||
|
if (typeof explicitProjectLocationId === 'string' && explicitProjectLocationId.trim()) {
|
||||||
|
return explicitProjectLocationId.trim();
|
||||||
|
}
|
||||||
|
const config = await readAppConfig(ctx.paths.RUNTIME_DATA_DIR);
|
||||||
|
const configuredDefault = typeof config.defaultProjectLocationId === 'string'
|
||||||
|
? config.defaultProjectLocationId.trim()
|
||||||
|
: '';
|
||||||
|
if (!configuredDefault || configuredDefault === BUILT_IN_PROJECT_LOCATION_ID) {
|
||||||
|
return BUILT_IN_PROJECT_LOCATION_ID;
|
||||||
|
}
|
||||||
|
const locations = await configuredProjectLocations();
|
||||||
|
return locations.some((location) => !location.builtIn && location.id === configuredDefault)
|
||||||
|
? configuredDefault
|
||||||
|
: BUILT_IN_PROJECT_LOCATION_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregisterProjectsForRemovedLocations(
|
||||||
|
previousLocations: Array<{ id: string; path: string; builtIn?: boolean }>,
|
||||||
|
nextLocations: Array<{ id?: string; path: string }>,
|
||||||
|
): string[] {
|
||||||
|
const nextIds = new Set(nextLocations.map((location) => location.id).filter(Boolean));
|
||||||
|
const nextPaths = new Set(nextLocations.map((location) => location.path));
|
||||||
|
const removed = previousLocations.filter(
|
||||||
|
(location) => !location.builtIn && !nextIds.has(location.id) && !nextPaths.has(location.path),
|
||||||
|
);
|
||||||
|
if (removed.length === 0) return [];
|
||||||
|
return listProjects(db)
|
||||||
|
.filter((project: any) => removed.some((location) => projectBelongsToLocation(project, location)))
|
||||||
|
.map((project: any) => project.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/project-locations', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const locations = await configuredProjectLocations();
|
||||||
|
/** @type {import('@open-design/contracts').ProjectLocationsResponse} */
|
||||||
|
const body = { locations };
|
||||||
|
res.json(body);
|
||||||
|
} catch (err: any) {
|
||||||
|
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/project-locations', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requested = Array.isArray(req.body?.locations) ? req.body.locations : null;
|
||||||
|
if (!requested) return sendApiError(res, 400, 'BAD_REQUEST', 'locations must be an array');
|
||||||
|
const previousLocations = await configuredProjectLocations();
|
||||||
|
const prepared = [];
|
||||||
|
for (const loc of requested) {
|
||||||
|
if (!loc || typeof loc !== 'object' || typeof loc.path !== 'string') continue;
|
||||||
|
const canonicalPath = await ensureProjectLocation(loc.path);
|
||||||
|
const validated = validateLinkedDirs([canonicalPath]);
|
||||||
|
if (validated.error) return sendApiError(res, 400, 'BAD_REQUEST', validated.error);
|
||||||
|
if (locationOverlapsDaemonData(canonicalPath)) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', 'project location cannot overlap daemon data');
|
||||||
|
}
|
||||||
|
prepared.push({
|
||||||
|
id: typeof loc.id === 'string' ? loc.id : undefined,
|
||||||
|
name: typeof loc.name === 'string' ? loc.name : undefined,
|
||||||
|
path: canonicalPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const config = await writeAppConfig(ctx.paths.RUNTIME_DATA_DIR, { projectLocations: prepared });
|
||||||
|
const locations = allProjectLocations(PROJECTS_DIR, config.projectLocations);
|
||||||
|
const removedProjectIds = unregisterProjectsForRemovedLocations(previousLocations, config.projectLocations ?? []);
|
||||||
|
/** @type {import('@open-design/contracts').ProjectLocationsResponse} */
|
||||||
|
const body = { locations, removedProjectIds };
|
||||||
|
res.json(body);
|
||||||
|
} catch (err: any) {
|
||||||
|
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/project-locations/scan', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const locations = (await configuredProjectLocations()).filter((loc: any) => !loc.builtIn);
|
||||||
|
const imported = [];
|
||||||
|
const existing: string[] = [];
|
||||||
|
const skipped: Array<{ path: string; reason: string }> = [];
|
||||||
|
let scanned = 0;
|
||||||
|
const now = Date.now();
|
||||||
|
for (const location of locations) {
|
||||||
|
let found;
|
||||||
|
try {
|
||||||
|
found = await scanProjectLocation(location);
|
||||||
|
} catch (err: any) {
|
||||||
|
skipped.push({ path: location.path, reason: String(err?.message ?? err) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
scanned += found.length;
|
||||||
|
for (const entry of found) {
|
||||||
|
const { manifest } = entry;
|
||||||
|
if (getProject(db, manifest.id)) {
|
||||||
|
existing.push(manifest.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const project = insertProject(db, {
|
||||||
|
id: manifest.id,
|
||||||
|
name: manifest.name,
|
||||||
|
skillId: manifest.skillId ?? null,
|
||||||
|
designSystemId: manifest.designSystemId ?? null,
|
||||||
|
pendingPrompt: null,
|
||||||
|
metadata: {
|
||||||
|
kind: 'prototype',
|
||||||
|
baseDir: entry.dir,
|
||||||
|
importedFrom: 'project-location',
|
||||||
|
projectLocationId: location.id,
|
||||||
|
},
|
||||||
|
customInstructions: null,
|
||||||
|
createdAt: manifest.createdAt,
|
||||||
|
updatedAt: manifest.updatedAt,
|
||||||
|
});
|
||||||
|
insertConversation(db, {
|
||||||
|
id: randomId(),
|
||||||
|
projectId: manifest.id,
|
||||||
|
title: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
if (project) imported.push(project);
|
||||||
|
} catch (err: any) {
|
||||||
|
skipped.push({ path: entry.dir, reason: String(err?.message ?? err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** @type {import('@open-design/contracts').ScanProjectLocationsResponse} */
|
||||||
|
const body = { scanned, imported, existing, skipped };
|
||||||
|
res.json(body);
|
||||||
|
} catch (err: any) {
|
||||||
|
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const locations = await configuredProjectLocations();
|
||||||
const latestRunStatuses = listLatestProjectRunStatuses(db);
|
const latestRunStatuses = listLatestProjectRunStatuses(db);
|
||||||
const awaitingInputProjects = listProjectsAwaitingInput(db);
|
const awaitingInputProjects = listProjectsAwaitingInput(db);
|
||||||
const activeRunStatuses = new Map();
|
const activeRunStatuses = new Map();
|
||||||
|
|
@ -224,15 +426,17 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
}
|
}
|
||||||
/** @type {import('@open-design/contracts').ProjectsResponse} */
|
/** @type {import('@open-design/contracts').ProjectsResponse} */
|
||||||
const body = {
|
const body = {
|
||||||
projects: listProjects(db).map((project: any) => ({
|
projects: listProjects(db)
|
||||||
...project,
|
.filter((project: any) => projectVisibleForLocations(project, locations))
|
||||||
status: composeProjectDisplayStatus(
|
.map((project: any) => ({
|
||||||
activeRunStatuses.get(project.id) ??
|
...project,
|
||||||
latestRunStatuses.get(project.id) ?? { value: 'not_started' },
|
status: composeProjectDisplayStatus(
|
||||||
awaitingInputProjects,
|
activeRunStatuses.get(project.id) ??
|
||||||
project.id,
|
latestRunStatuses.get(project.id) ?? { value: 'not_started' },
|
||||||
),
|
awaitingInputProjects,
|
||||||
})),
|
project.id,
|
||||||
|
),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
res.json(body);
|
res.json(body);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -250,9 +454,9 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
|
|
||||||
app.post('/api/projects', async (req, res) => {
|
app.post('/api/projects', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id, name, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } =
|
const { id, name, projectLocationId, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } =
|
||||||
req.body || {};
|
req.body || {};
|
||||||
if (typeof id !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(id)) {
|
if (typeof id !== 'string' || !isSafeId(id)) {
|
||||||
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
|
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
|
||||||
}
|
}
|
||||||
if (typeof name !== 'string' || !name.trim()) {
|
if (typeof name !== 'string' || !name.trim()) {
|
||||||
|
|
@ -306,11 +510,30 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
return sendApiError(res, 400, skillValidation.code, skillValidation.message);
|
return sendApiError(res, 400, skillValidation.code, skillValidation.message);
|
||||||
}
|
}
|
||||||
const normalizedSkillId = skillValidation.id;
|
const normalizedSkillId = skillValidation.id;
|
||||||
|
const selectedLocationId = await resolveCreateProjectLocationId(projectLocationId);
|
||||||
|
let externalProjectDir: string | null = null;
|
||||||
|
if (selectedLocationId !== BUILT_IN_PROJECT_LOCATION_ID) {
|
||||||
|
const location = (await configuredProjectLocations()).find((loc: any) => loc.id === selectedLocationId);
|
||||||
|
if (!location || location.builtIn) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', 'unknown project location');
|
||||||
|
}
|
||||||
|
if (getProject(db, id)) {
|
||||||
|
return sendApiError(res, 400, 'BAD_REQUEST', 'project id already exists');
|
||||||
|
}
|
||||||
|
externalProjectDir = await createLocationProjectDir(location, id);
|
||||||
|
}
|
||||||
const projectMetadata =
|
const projectMetadata =
|
||||||
metadata && typeof metadata === 'object'
|
metadata && typeof metadata === 'object'
|
||||||
? {
|
? {
|
||||||
...metadata,
|
...metadata,
|
||||||
...(skipDiscoveryBrief === true ? { skipDiscoveryBrief: true } : {}),
|
...(skipDiscoveryBrief === true ? { skipDiscoveryBrief: true } : {}),
|
||||||
|
...(externalProjectDir
|
||||||
|
? {
|
||||||
|
baseDir: externalProjectDir,
|
||||||
|
importedFrom: 'project-location',
|
||||||
|
projectLocationId: selectedLocationId,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(Array.isArray(metadata.linkedDirs)
|
...(Array.isArray(metadata.linkedDirs)
|
||||||
? (() => {
|
? (() => {
|
||||||
const v = validateLinkedDirs(metadata.linkedDirs);
|
const v = validateLinkedDirs(metadata.linkedDirs);
|
||||||
|
|
@ -319,23 +542,58 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
: {}),
|
: {}),
|
||||||
}
|
}
|
||||||
: skipDiscoveryBrief === true
|
: skipDiscoveryBrief === true
|
||||||
? { skipDiscoveryBrief: true }
|
? {
|
||||||
: null;
|
skipDiscoveryBrief: true,
|
||||||
|
...(externalProjectDir
|
||||||
|
? {
|
||||||
|
baseDir: externalProjectDir,
|
||||||
|
importedFrom: 'project-location',
|
||||||
|
projectLocationId: selectedLocationId,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
: externalProjectDir
|
||||||
|
? {
|
||||||
|
kind: 'prototype',
|
||||||
|
baseDir: externalProjectDir,
|
||||||
|
importedFrom: 'project-location',
|
||||||
|
projectLocationId: selectedLocationId,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const project = insertProject(db, {
|
let project;
|
||||||
id,
|
try {
|
||||||
name: name.trim(),
|
if (externalProjectDir) {
|
||||||
skillId: normalizedSkillId,
|
await writeProjectManifest(externalProjectDir, {
|
||||||
designSystemId: normalizedDesignSystemId,
|
schemaVersion: 1,
|
||||||
pendingPrompt: pendingPrompt || null,
|
id,
|
||||||
metadata: projectMetadata,
|
name: name.trim(),
|
||||||
customInstructions:
|
createdAt: now,
|
||||||
typeof customInstructions === 'string'
|
updatedAt: now,
|
||||||
? customInstructions
|
skillId: normalizedSkillId,
|
||||||
: null,
|
designSystemId: normalizedDesignSystemId,
|
||||||
createdAt: now,
|
});
|
||||||
updatedAt: now,
|
}
|
||||||
});
|
project = insertProject(db, {
|
||||||
|
id,
|
||||||
|
name: name.trim(),
|
||||||
|
skillId: normalizedSkillId,
|
||||||
|
designSystemId: normalizedDesignSystemId,
|
||||||
|
pendingPrompt: pendingPrompt || null,
|
||||||
|
metadata: projectMetadata,
|
||||||
|
customInstructions:
|
||||||
|
typeof customInstructions === 'string'
|
||||||
|
? customInstructions
|
||||||
|
: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (externalProjectDir) {
|
||||||
|
await rm(externalProjectDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
// Seed a default conversation so the UI always has somewhere to write.
|
// Seed a default conversation so the UI always has somewhere to write.
|
||||||
const cid = randomId();
|
const cid = randomId();
|
||||||
insertConversation(db, {
|
insertConversation(db, {
|
||||||
|
|
@ -345,7 +603,6 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
const explicitPlugin =
|
const explicitPlugin =
|
||||||
typeof req.body?.pluginId === 'string' && req.body.pluginId.trim().length > 0
|
typeof req.body?.pluginId === 'string' && req.body.pluginId.trim().length > 0
|
||||||
? true
|
? true
|
||||||
|
|
@ -398,7 +655,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
) {
|
) {
|
||||||
const tpl = getTemplate(db, metadata.templateId);
|
const tpl = getTemplate(db, metadata.templateId);
|
||||||
if (tpl && Array.isArray(tpl.files) && tpl.files.length > 0) {
|
if (tpl && Array.isArray(tpl.files) && tpl.files.length > 0) {
|
||||||
await ensureProject(PROJECTS_DIR, id);
|
await ensureProject(PROJECTS_DIR, id, projectMetadata);
|
||||||
for (const f of tpl.files) {
|
for (const f of tpl.files) {
|
||||||
if (
|
if (
|
||||||
!f ||
|
!f ||
|
||||||
|
|
@ -413,6 +670,8 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
id,
|
id,
|
||||||
f.name,
|
f.name,
|
||||||
Buffer.from(f.content, 'utf8'),
|
Buffer.from(f.content, 'utf8'),
|
||||||
|
{},
|
||||||
|
projectMetadata,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Skip individual file failures — the template snapshot is
|
// Skip individual file failures — the template snapshot is
|
||||||
|
|
@ -435,9 +694,10 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/projects/:id', (req, res) => {
|
app.get('/api/projects/:id', async (req, res) => {
|
||||||
const project = getProject(db, req.params.id);
|
const project = getProject(db, req.params.id);
|
||||||
if (!project)
|
const locations = await configuredProjectLocations();
|
||||||
|
if (!project || !projectVisibleForLocations(project, locations))
|
||||||
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
|
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
|
||||||
const resolvedDir = projectDetailResolvedDir(PROJECTS_DIR, project, resolveProjectDir);
|
const resolvedDir = projectDetailResolvedDir(PROJECTS_DIR, project, resolveProjectDir);
|
||||||
/** @type {import('@open-design/contracts').ProjectResponse} */
|
/** @type {import('@open-design/contracts').ProjectResponse} */
|
||||||
|
|
@ -484,6 +744,12 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
...(existingMeta.importedFrom === 'folder'
|
...(existingMeta.importedFrom === 'folder'
|
||||||
? { importedFrom: 'folder' }
|
? { importedFrom: 'folder' }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(existingMeta.importedFrom === 'project-location'
|
||||||
|
? { importedFrom: 'project-location' }
|
||||||
|
: {}),
|
||||||
|
...(typeof existingMeta.projectLocationId === 'string'
|
||||||
|
? { projectLocationId: existingMeta.projectLocationId }
|
||||||
|
: {}),
|
||||||
...(existingMeta.fromTrustedPicker === true
|
...(existingMeta.fromTrustedPicker === true
|
||||||
? { fromTrustedPicker: true as const }
|
? { fromTrustedPicker: true as const }
|
||||||
: {}),
|
: {}),
|
||||||
|
|
|
||||||
|
|
@ -5733,6 +5733,7 @@ export async function startServer({
|
||||||
events: projectEventDeps,
|
events: projectEventDeps,
|
||||||
ids: idDeps,
|
ids: idDeps,
|
||||||
telemetry: { reportFinalizedMessage },
|
telemetry: { reportFinalizedMessage },
|
||||||
|
appConfig: appConfigDeps,
|
||||||
validation: validationDeps,
|
validation: validationDeps,
|
||||||
});
|
});
|
||||||
registerImportRoutes(app, {
|
registerImportRoutes(app, {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { homedir, tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import {
|
import {
|
||||||
|
|
@ -623,6 +623,187 @@ describe('app-config telemetry prefs', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('app-config projectLocations', () => {
|
||||||
|
let dataDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dataDir = await mkdtemp(path.join(tmpdir(), 'od-projectLocations-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists valid projectLocations and reads them back', async () => {
|
||||||
|
const locs = [
|
||||||
|
{ id: 'ext-one', name: 'One', path: '/tmp/od-loc-one' },
|
||||||
|
{ id: 'ext-two', name: 'Two', path: '/tmp/od-loc-two' },
|
||||||
|
];
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: locs });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toEqual(locs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes ~/ paths via expandHomePrefix', async () => {
|
||||||
|
const home = homedir();
|
||||||
|
const locs = [{ id: 'home-loc', name: 'Home', path: '~/od-projects' }];
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: locs });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toHaveLength(1);
|
||||||
|
const first = cfg.projectLocations![0]!;
|
||||||
|
expect(first.path).toBe(path.join(home, 'od-projects'));
|
||||||
|
expect(path.isAbsolute(first.path)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops relative paths that cannot be resolved to absolute', async () => {
|
||||||
|
const locs = [
|
||||||
|
{ id: 'good', name: 'Good', path: '/tmp/od-good' },
|
||||||
|
{ id: 'bad-relative', name: 'Bad Rel', path: './relative/path' },
|
||||||
|
];
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: locs });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toHaveLength(1);
|
||||||
|
const first = cfg.projectLocations![0]!;
|
||||||
|
expect(first.id).toBe('good');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops entries without a string path', async () => {
|
||||||
|
const locs = [
|
||||||
|
{ id: 'good', name: 'Good', path: '/tmp/od-good' },
|
||||||
|
{ id: 'no-path', name: 'No Path' },
|
||||||
|
];
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: locs as any });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toHaveLength(1);
|
||||||
|
const first = cfg.projectLocations![0]!;
|
||||||
|
expect(first.id).toBe('good');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates paths (case-sensitive on unix)', async () => {
|
||||||
|
const locs = [
|
||||||
|
{ id: 'first', name: 'First', path: '/tmp/od-same' },
|
||||||
|
{ id: 'second', name: 'Second', path: '/tmp/od-same' },
|
||||||
|
];
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: locs });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
// Single canonical entry, second deduplicated
|
||||||
|
expect(cfg.projectLocations).toHaveLength(1);
|
||||||
|
const first = cfg.projectLocations![0]!;
|
||||||
|
expect(first.path).toBe(path.normalize('/tmp/od-same'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates by resolved path after normalization', async () => {
|
||||||
|
const locs = [
|
||||||
|
{ id: 'first', name: 'First', path: '/tmp/od-dup/../od-dup' },
|
||||||
|
{ id: 'second', name: 'Second', path: '/tmp/od-dup' },
|
||||||
|
];
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: locs });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toHaveLength(1);
|
||||||
|
const first = cfg.projectLocations![0]!;
|
||||||
|
expect(first.path).toBe(path.normalize('/tmp/od-dup'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects reserved id "default" and falls back to auto-generated id', async () => {
|
||||||
|
const locs = [{ id: 'default', name: 'Hijack', path: '/tmp/od-hijack' }];
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: locs });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toHaveLength(1);
|
||||||
|
// The stored id must NOT be 'default'
|
||||||
|
const first = cfg.projectLocations![0]!;
|
||||||
|
expect(first.id).not.toBe('default');
|
||||||
|
// The auto-generated id follows the hash-backed base64url pattern
|
||||||
|
expect(first.id).toMatch(/^loc_[A-Za-z0-9_-]{1,16}$/);
|
||||||
|
expect(first.path).toBe(path.normalize('/tmp/od-hijack'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates distinct ids for sibling paths with long shared prefixes', async () => {
|
||||||
|
const locs = [
|
||||||
|
{ path: '/tmp/open-design-project-locations/shared-prefix-one' },
|
||||||
|
{ path: '/tmp/open-design-project-locations/shared-prefix-two' },
|
||||||
|
];
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: locs });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toHaveLength(2);
|
||||||
|
const ids = cfg.projectLocations!.map((location) => location.id);
|
||||||
|
expect(new Set(ids).size).toBe(2);
|
||||||
|
expect(ids.every((id) => /^loc_[A-Za-z0-9_-]{1,16}$/.test(id))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists a defaultProjectLocationId preference', async () => {
|
||||||
|
await writeAppConfig(dataDir, {
|
||||||
|
projectLocations: [{ id: 'external-default', name: 'External', path: '/tmp/od-default-location' }],
|
||||||
|
defaultProjectLocationId: 'external-default',
|
||||||
|
});
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.defaultProjectLocationId).toBe('external-default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes invalid defaultProjectLocationId values', async () => {
|
||||||
|
await writeAppConfig(dataDir, { defaultProjectLocationId: '../bad' });
|
||||||
|
let cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.defaultProjectLocationId).toBe('default');
|
||||||
|
|
||||||
|
await writeAppConfig(dataDir, { defaultProjectLocationId: null });
|
||||||
|
cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.defaultProjectLocationId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops invalid scalar projectLocations (not an array)', async () => {
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: 'not-array' } as any);
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears projectLocations when empty array is sent', async () => {
|
||||||
|
await writeAppConfig(dataDir, {
|
||||||
|
projectLocations: [{ id: 'ext', name: 'ext', path: '/tmp/od-ext' }],
|
||||||
|
onboardingCompleted: true,
|
||||||
|
});
|
||||||
|
expect((await readAppConfig(dataDir)).projectLocations).toHaveLength(1);
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: [] });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toEqual([]);
|
||||||
|
expect(cfg.onboardingCompleted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears projectLocations when null is sent', async () => {
|
||||||
|
await writeAppConfig(dataDir, {
|
||||||
|
projectLocations: [{ id: 'ext', name: 'ext', path: '/tmp/od-ext' }],
|
||||||
|
onboardingCompleted: true,
|
||||||
|
});
|
||||||
|
expect((await readAppConfig(dataDir)).projectLocations).toHaveLength(1);
|
||||||
|
await writeAppConfig(dataDir, { projectLocations: null as any });
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toBeUndefined();
|
||||||
|
expect(cfg.onboardingCompleted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates projectLocations on read (filters corrupted stored data)', async () => {
|
||||||
|
// Write raw JSON with invalid entries
|
||||||
|
await writeFile(
|
||||||
|
path.join(dataDir, 'app-config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
projectLocations: [
|
||||||
|
{ id: 'good', name: 'Good', path: '/tmp/od-good' },
|
||||||
|
{ id: 'bad-relative', name: 'Bad', path: 'relative' },
|
||||||
|
{ id: 'no-path', name: 'No Path' },
|
||||||
|
'not-an-object',
|
||||||
|
null,
|
||||||
|
{ id: 'good2', name: 'Dup Path', path: '/tmp/od-good' },
|
||||||
|
{ id: 'default', name: 'Reserved', path: '/tmp/od-reserved' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const cfg = await readAppConfig(dataDir);
|
||||||
|
expect(cfg.projectLocations).toHaveLength(2);
|
||||||
|
const ids = cfg.projectLocations!.map((l) => l.id);
|
||||||
|
expect(ids).not.toContain('default');
|
||||||
|
expect(ids).not.toContain('bad-relative');
|
||||||
|
expect(ids).not.toContain('no-path');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('app-config origin guard', () => {
|
describe('app-config origin guard', () => {
|
||||||
let server: http.Server;
|
let server: http.Server;
|
||||||
let port: number;
|
let port: number;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
*/
|
*/
|
||||||
import type http from 'node:http';
|
import type http from 'node:http';
|
||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
import { writeFile } from 'node:fs/promises';
|
import { mkdir, readdir, readFile, realpath, symlink, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
@ -299,6 +299,644 @@ describe('GET /api/projects/:id resolvedDir', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Project locations routes: GET, PUT, scan, and project creation under an
|
||||||
|
// external project location.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('project locations routes', () => {
|
||||||
|
let server: http.Server;
|
||||||
|
let baseUrl: string;
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const started = (await startServer({ port: 0, returnServer: true })) as {
|
||||||
|
url: string;
|
||||||
|
server: http.Server;
|
||||||
|
};
|
||||||
|
baseUrl = started.url;
|
||||||
|
server = started.server;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
return new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeTempDir(): string {
|
||||||
|
const d = mkdtempSync(path.join(tmpdir(), 'od-proj-loc-routes-'));
|
||||||
|
tempDirs.push(d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putProjectLocations(
|
||||||
|
locations: Array<{ id?: string; name?: string; path: string }>,
|
||||||
|
): Promise<Response> {
|
||||||
|
return fetch(`${baseUrl}/api/project-locations`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ locations }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putAppConfig(config: Record<string, unknown>): Promise<Response> {
|
||||||
|
return fetch(`${baseUrl}/api/app-config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('GET /api/project-locations returns built-in default plus empty external', async () => {
|
||||||
|
const resp = await fetch(`${baseUrl}/api/project-locations`);
|
||||||
|
expect(resp.status).toBe(200);
|
||||||
|
const body = (await resp.json()) as { locations: Array<{ id: string; name: string; builtIn?: boolean; path: string }> };
|
||||||
|
expect(body.locations).toHaveLength(1); // only default on fresh start
|
||||||
|
const loc0 = body.locations[0]!;
|
||||||
|
expect(loc0.id).toBe('default');
|
||||||
|
expect(loc0.builtIn).toBe(true);
|
||||||
|
expect(loc0.name).toBe('Open Design projects');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /api/project-locations creates external roots and GET returns them alongside default', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
const resp = await putProjectLocations([
|
||||||
|
{ id: 'ext-root', name: 'External', path: extDir },
|
||||||
|
]);
|
||||||
|
expect(resp.status).toBe(200);
|
||||||
|
const putBody = (await resp.json()) as { locations: Array<{ id: string; builtIn?: boolean; path: string }> };
|
||||||
|
expect(putBody.locations).toHaveLength(2);
|
||||||
|
const putLoc0 = putBody.locations[0]!;
|
||||||
|
const putLoc1 = putBody.locations[1]!;
|
||||||
|
expect(putLoc0.id).toBe('default');
|
||||||
|
expect(putLoc1.id).toBe('ext-root');
|
||||||
|
expect(putLoc1.path).toBe(await realpath(extDir));
|
||||||
|
|
||||||
|
// GET returns the same
|
||||||
|
const getResp = await fetch(`${baseUrl}/api/project-locations`);
|
||||||
|
expect(getResp.status).toBe(200);
|
||||||
|
const getBody = (await getResp.json()) as { locations: Array<{ id: string; builtIn?: boolean; path: string }> };
|
||||||
|
expect(getBody.locations).toHaveLength(2);
|
||||||
|
const getLoc0 = getBody.locations[0]!;
|
||||||
|
const getLoc1 = getBody.locations[1]!;
|
||||||
|
expect(getLoc0.id).toBe('default');
|
||||||
|
expect(getLoc1.id).toBe('ext-root');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/project-locations/scan returns empty result when no manifests found', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
await putProjectLocations([{ id: 'empty-ext', name: 'Empty', path: extDir }]);
|
||||||
|
|
||||||
|
const scanResp = await fetch(`${baseUrl}/api/project-locations/scan`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
expect(scanResp.status).toBe(200);
|
||||||
|
const body = (await scanResp.json()) as {
|
||||||
|
scanned: number;
|
||||||
|
imported: unknown[];
|
||||||
|
existing: string[];
|
||||||
|
skipped: unknown[];
|
||||||
|
};
|
||||||
|
expect(body.scanned).toBe(0);
|
||||||
|
expect(body.imported).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/project-locations/scan imports manifest-backed project and skips on re-scan', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
// Create a project directory with a valid manifest
|
||||||
|
const projectDir = path.join(extDir, 'scan-test-proj');
|
||||||
|
const odDir = path.join(projectDir, '.open-design');
|
||||||
|
await mkdir(odDir, { recursive: true });
|
||||||
|
const manifest = {
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
id: 'scan-test-proj',
|
||||||
|
name: 'Scanned Project',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
skillId: null,
|
||||||
|
designSystemId: null,
|
||||||
|
};
|
||||||
|
await writeFile(
|
||||||
|
path.join(projectDir, '.open-design', 'project.json'),
|
||||||
|
JSON.stringify(manifest, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register the location
|
||||||
|
await putProjectLocations([{ id: 'scan-ext', name: 'Scan External', path: extDir }]);
|
||||||
|
|
||||||
|
// First scan: should import
|
||||||
|
const scan1 = await fetch(`${baseUrl}/api/project-locations/scan`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
expect(scan1.status).toBe(200);
|
||||||
|
const body1 = (await scan1.json()) as {
|
||||||
|
scanned: number;
|
||||||
|
imported: Array<{ id: string; name: string; metadata?: { baseDir?: string; importedFrom?: string } }>;
|
||||||
|
existing: string[];
|
||||||
|
skipped: unknown[];
|
||||||
|
};
|
||||||
|
expect(body1.scanned).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(body1.imported).toHaveLength(1);
|
||||||
|
const imported0 = body1.imported[0]!;
|
||||||
|
expect(imported0.id).toBe('scan-test-proj');
|
||||||
|
expect(imported0.name).toBe('Scanned Project');
|
||||||
|
// The imported project should have metadata pointing at the external dir
|
||||||
|
// (ensureProjectLocation calls realpath which resolves /var -> /private/var on macOS)
|
||||||
|
expect(imported0.metadata?.baseDir).toBe(await realpath(projectDir));
|
||||||
|
expect(imported0.metadata?.importedFrom).toBe('project-location');
|
||||||
|
expect(body1.existing).toEqual([]);
|
||||||
|
|
||||||
|
// Second scan: project already exists, should be in "existing"
|
||||||
|
const scan2 = await fetch(`${baseUrl}/api/project-locations/scan`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
expect(scan2.status).toBe(200);
|
||||||
|
const body2 = (await scan2.json()) as {
|
||||||
|
scanned: number;
|
||||||
|
imported: unknown[];
|
||||||
|
existing: string[];
|
||||||
|
};
|
||||||
|
expect(body2.imported).toEqual([]);
|
||||||
|
expect(body2.existing).toEqual(['scan-test-proj']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/projects with projectLocationId creates project under external root and writes .open-design/project.json', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
// Register an external location
|
||||||
|
await putProjectLocations([{ id: 'create-ext', name: 'Create External', path: extDir }]);
|
||||||
|
|
||||||
|
const projectId = `ext-proj-${Date.now()}`;
|
||||||
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'External Project',
|
||||||
|
skillId: null,
|
||||||
|
designSystemId: null,
|
||||||
|
projectLocationId: 'create-ext',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(createResp.status).toBe(200);
|
||||||
|
const createBody = (await createResp.json()) as {
|
||||||
|
project: { id: string; metadata?: { baseDir?: string; importedFrom?: string; projectLocationId?: string } };
|
||||||
|
};
|
||||||
|
expect(createBody.project.id).toBe(projectId);
|
||||||
|
expect(createBody.project.metadata?.importedFrom).toBe('project-location');
|
||||||
|
expect(createBody.project.metadata?.projectLocationId).toBe('create-ext');
|
||||||
|
|
||||||
|
// The project should be under <extDir>/<projectId> (ensureProjectLocation realpaths)
|
||||||
|
const expectedProjectDir = await realpath(path.join(extDir, projectId));
|
||||||
|
expect(createBody.project.metadata?.baseDir).toBe(expectedProjectDir);
|
||||||
|
|
||||||
|
// Verify .open-design/project.json was written
|
||||||
|
const manifestPath = path.join(expectedProjectDir, '.open-design', 'project.json');
|
||||||
|
const manifestRaw = await import('node:fs/promises').then((m) => m.readFile(manifestPath, 'utf8'));
|
||||||
|
const manifest = JSON.parse(manifestRaw);
|
||||||
|
expect(manifest.schemaVersion).toBe(1);
|
||||||
|
expect(manifest.id).toBe(projectId);
|
||||||
|
expect(manifest.name).toBe('External Project');
|
||||||
|
|
||||||
|
// GET /api/projects/:id resolvedDir equals the external project dir
|
||||||
|
const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`);
|
||||||
|
expect(detailResp.status).toBe(200);
|
||||||
|
const detail = (await detailResp.json()) as { resolvedDir: string };
|
||||||
|
expect(detail.resolvedDir).toBe(expectedProjectDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/projects uses the configured default project location when no location is supplied', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
const locationId = 'default-create-location';
|
||||||
|
await putProjectLocations([{ id: locationId, name: 'Default External', path: extDir }]);
|
||||||
|
const cfgResp = await putAppConfig({ defaultProjectLocationId: locationId });
|
||||||
|
expect(cfgResp.status).toBe(200);
|
||||||
|
|
||||||
|
const projectId = `default-location-project-${Date.now()}`;
|
||||||
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'Default location project',
|
||||||
|
skillId: null,
|
||||||
|
designSystemId: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(createResp.status).toBe(200);
|
||||||
|
const body = (await createResp.json()) as {
|
||||||
|
project: { metadata?: { baseDir?: string; projectLocationId?: string; importedFrom?: string } };
|
||||||
|
};
|
||||||
|
expect(body.project.metadata?.projectLocationId).toBe(locationId);
|
||||||
|
expect(body.project.metadata?.importedFrom).toBe('project-location');
|
||||||
|
expect(body.project.metadata?.baseDir).toBe(await realpath(path.join(extDir, projectId)));
|
||||||
|
|
||||||
|
await putAppConfig({ defaultProjectLocationId: null });
|
||||||
|
await putProjectLocations([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/projects falls back to built-in storage when configured default location is unavailable', async () => {
|
||||||
|
await putProjectLocations([]);
|
||||||
|
const cfgResp = await putAppConfig({ defaultProjectLocationId: 'missing-location' });
|
||||||
|
expect(cfgResp.status).toBe(200);
|
||||||
|
|
||||||
|
const projectId = `missing-default-project-${Date.now()}`;
|
||||||
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'Missing default project',
|
||||||
|
skillId: null,
|
||||||
|
designSystemId: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(createResp.status).toBe(200);
|
||||||
|
const body = (await createResp.json()) as {
|
||||||
|
project: { metadata?: { baseDir?: string; projectLocationId?: string } };
|
||||||
|
};
|
||||||
|
expect(body.project.metadata?.baseDir).toBeUndefined();
|
||||||
|
expect(body.project.metadata?.projectLocationId).toBeUndefined();
|
||||||
|
|
||||||
|
await putAppConfig({ defaultProjectLocationId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH /api/projects/:id preserves project-location provenance with baseDir', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
await putProjectLocations([{ id: 'patch-ext', name: 'Patch External', path: extDir }]);
|
||||||
|
|
||||||
|
const projectId = `ext-patch-${Date.now()}`;
|
||||||
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'Patch External Project',
|
||||||
|
projectLocationId: 'patch-ext',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(createResp.status).toBe(200);
|
||||||
|
const createBody = (await createResp.json()) as {
|
||||||
|
project: { metadata?: { baseDir?: string; importedFrom?: string; projectLocationId?: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchResp = await fetch(`${baseUrl}/api/projects/${projectId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ metadata: { kind: 'prototype', skipDiscoveryBrief: true } }),
|
||||||
|
});
|
||||||
|
expect(patchResp.status).toBe(200);
|
||||||
|
const patchBody = (await patchResp.json()) as {
|
||||||
|
project: { metadata?: { baseDir?: string; importedFrom?: string; projectLocationId?: string; skipDiscoveryBrief?: boolean } };
|
||||||
|
};
|
||||||
|
expect(patchBody.project.metadata?.baseDir).toBe(createBody.project.metadata?.baseDir);
|
||||||
|
expect(patchBody.project.metadata?.importedFrom).toBe('project-location');
|
||||||
|
expect(patchBody.project.metadata?.projectLocationId).toBe('patch-ext');
|
||||||
|
expect(patchBody.project.metadata?.skipDiscoveryBrief).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/projects with unknown projectLocationId returns 400', async () => {
|
||||||
|
const projectId = `bad-loc-${Date.now()}`;
|
||||||
|
const resp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'Bad Location Project',
|
||||||
|
projectLocationId: 'nonexistent-location-id',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
|
||||||
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
||||||
|
expect(body.error?.message).toMatch(/project location/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/projects with invalid designSystemId does not create external project directory', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
await putProjectLocations([{ id: 'invalid-ds-ext', name: 'Invalid DS External', path: extDir }]);
|
||||||
|
|
||||||
|
const projectId = `invalid-ds-${Date.now()}`;
|
||||||
|
const resp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'Invalid design system project',
|
||||||
|
designSystemId: `missing-design-system-${Date.now()}`,
|
||||||
|
projectLocationId: 'invalid-ds-ext',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
const body = (await resp.json()) as { error?: { code?: string } };
|
||||||
|
expect(body.error?.code).toBe('DESIGN_SYSTEM_NOT_FOUND');
|
||||||
|
await expect(readdir(extDir)).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /api/project-locations rejects non-array locations body', async () => {
|
||||||
|
const resp = await fetch(`${baseUrl}/api/project-locations`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ locations: 'not-an-array' }),
|
||||||
|
});
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
const body = (await resp.json()) as { error?: { code?: string } };
|
||||||
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Security boundaries — see #451 (project-locations) for context.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('POST /api/projects with projectLocationId rejects unsafe id "."', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
await putProjectLocations([{ id: 'sec-ext', name: 'Security External', path: extDir }]);
|
||||||
|
|
||||||
|
const resp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: '.',
|
||||||
|
name: 'Dot Project',
|
||||||
|
projectLocationId: 'sec-ext',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
|
||||||
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
||||||
|
expect(body.error?.message).toMatch(/invalid project id/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/projects with projectLocationId rejects unsafe id ".."', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
await putProjectLocations([{ id: 'sec-ext2', name: 'Security External 2', path: extDir }]);
|
||||||
|
|
||||||
|
const resp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: '..',
|
||||||
|
name: 'DotDot Project',
|
||||||
|
projectLocationId: 'sec-ext2',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
|
||||||
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
||||||
|
expect(body.error?.message).toMatch(/invalid project id/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/projects with projectLocationId rejects when target path already exists as a symlink', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
await putProjectLocations([{ id: 'sym-ext', name: 'Symlink External', path: extDir }]);
|
||||||
|
|
||||||
|
const projectId = `symlink-proj-${Date.now()}`;
|
||||||
|
const realTargetDir = path.join(extDir, 'real-target');
|
||||||
|
await mkdir(realTargetDir, { recursive: true });
|
||||||
|
|
||||||
|
// Pre-create a symlink at <extDir>/<projectId> pointing to another directory
|
||||||
|
const symlinkPath = path.join(extDir, projectId);
|
||||||
|
await symlink(realTargetDir, symlinkPath);
|
||||||
|
|
||||||
|
const resp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'Symlink Project',
|
||||||
|
projectLocationId: 'sym-ext',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
|
||||||
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /api/project-locations rejects a root overlapping the daemon projects dir', async () => {
|
||||||
|
const dataDir = process.env.OD_DATA_DIR;
|
||||||
|
if (!dataDir) throw new Error('OD_DATA_DIR required for daemon route tests');
|
||||||
|
const projectsDir = path.join(dataDir, 'projects');
|
||||||
|
|
||||||
|
const canonicalProjectsDir = await realpath(projectsDir);
|
||||||
|
|
||||||
|
const resp = await putProjectLocations([
|
||||||
|
{ id: 'overlap-projects', name: 'Overlap Projects', path: canonicalProjectsDir },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
|
||||||
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
||||||
|
expect(body.error?.message).toMatch(/cannot overlap|daemon data/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /api/project-locations rejects filesystem root "/" via isBlocked check', async () => {
|
||||||
|
// isBlocked in linked-dirs.ts rejects the filesystem root.
|
||||||
|
const resp = await putProjectLocations([
|
||||||
|
{ id: 'root-loc', name: 'Root', path: '/' },
|
||||||
|
]);
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
|
||||||
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('app-config bypass: PUT /api/app-config persists invalid path but GET /api/project-locations does not expose it', async () => {
|
||||||
|
// Persist a projectLocations entry with a system-protected path ('/') via
|
||||||
|
// the generic PUT /api/app-config route, which only validates format, not
|
||||||
|
// safety. The GET /api/project-locations route must filter it out because
|
||||||
|
// configuredProjectLocations() runs validateLinkedDirs + locationOverlapsDaemonData.
|
||||||
|
const appCfgResp = await fetch(`${baseUrl}/api/app-config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectLocations: [
|
||||||
|
{ id: 'bad-root', name: 'Bad Root', path: '/' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(appCfgResp.status).toBe(200);
|
||||||
|
|
||||||
|
// Verify the persisted config (read back) contains the entry (format validation passed)
|
||||||
|
const readCfgResp = await fetch(`${baseUrl}/api/app-config`);
|
||||||
|
expect(readCfgResp.status).toBe(200);
|
||||||
|
const cfgBody = (await readCfgResp.json()) as {
|
||||||
|
config: { projectLocations?: Array<{ id: string; path: string }> };
|
||||||
|
};
|
||||||
|
// The entry was normalized and persisted
|
||||||
|
const locs = cfgBody.config.projectLocations;
|
||||||
|
expect(locs).toBeDefined();
|
||||||
|
expect(locs!.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// But GET /api/project-locations must NOT expose it
|
||||||
|
const locResp = await fetch(`${baseUrl}/api/project-locations`);
|
||||||
|
expect(locResp.status).toBe(200);
|
||||||
|
const locBody = (await locResp.json()) as {
|
||||||
|
locations: Array<{ id: string }>;
|
||||||
|
};
|
||||||
|
const ids = locBody.locations.map((l) => l.id);
|
||||||
|
expect(ids).toContain('default'); // built-in always present
|
||||||
|
// The invalid location must not appear
|
||||||
|
expect(ids).not.toContain('bad-root');
|
||||||
|
|
||||||
|
// Clean up: remove the invalid projectLocations
|
||||||
|
await fetch(`${baseUrl}/api/app-config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ projectLocations: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('app-config bypass: POST /api/projects with invalid persisted root id returns 400 unknown project location', async () => {
|
||||||
|
// Persist a projectLocations entry with '/' via app-config.
|
||||||
|
// The auto-generated id follows the loc_<base64url> pattern.
|
||||||
|
const appCfgResp = await fetch(`${baseUrl}/api/app-config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectLocations: [
|
||||||
|
{ id: 'evil-root', name: 'Evil Root', path: '/' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(appCfgResp.status).toBe(200);
|
||||||
|
|
||||||
|
// Try to create a project under this location id. Since configuredProjectLocations
|
||||||
|
// filters it, the lookup returns nothing → 400 "unknown project location".
|
||||||
|
const projectId = `evil-proj-${Date.now()}`;
|
||||||
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'Evil Project',
|
||||||
|
projectLocationId: 'evil-root',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(createResp.status).toBe(400);
|
||||||
|
const body = (await createResp.json()) as { error?: { code?: string; message?: string } };
|
||||||
|
expect(body.error?.code).toBe('BAD_REQUEST');
|
||||||
|
expect(body.error?.message).toMatch(/unknown project location/i);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await fetch(`${baseUrl}/api/app-config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ projectLocations: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removing an external location hides its projects but preserves DB history and disk files for re-scan', async () => {
|
||||||
|
const extDir = makeTempDir();
|
||||||
|
const locationId = 'unreg-loc';
|
||||||
|
await putProjectLocations([{ id: locationId, name: 'Unreg External', path: extDir }]);
|
||||||
|
|
||||||
|
// Create a project under this external location
|
||||||
|
const projectId = `unreg-proj-${Date.now()}`;
|
||||||
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: projectId,
|
||||||
|
name: 'Project To Unregister',
|
||||||
|
skillId: null,
|
||||||
|
designSystemId: null,
|
||||||
|
projectLocationId: locationId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(createResp.status).toBe(200);
|
||||||
|
const createBody = (await createResp.json()) as {
|
||||||
|
project: { id: string };
|
||||||
|
conversationId: string;
|
||||||
|
};
|
||||||
|
expect(createBody.project.id).toBe(projectId);
|
||||||
|
|
||||||
|
const messageId = `msg-${Date.now()}`;
|
||||||
|
const messageResp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations/${createBody.conversationId}/messages/${messageId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
role: 'user',
|
||||||
|
content: 'restore this conversation after location re-add',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(messageResp.status).toBe(200);
|
||||||
|
|
||||||
|
// Confirm the project is listed
|
||||||
|
const listBefore = await fetch(`${baseUrl}/api/projects`);
|
||||||
|
expect(listBefore.status).toBe(200);
|
||||||
|
const beforeBody = (await listBefore.json()) as { projects: Array<{ id: string }> };
|
||||||
|
expect(beforeBody.projects.some((p) => p.id === projectId)).toBe(true);
|
||||||
|
|
||||||
|
// The project directory and manifest should exist on disk
|
||||||
|
const expectedProjectDir = await realpath(path.join(extDir, projectId));
|
||||||
|
const manifestPath = path.join(expectedProjectDir, '.open-design', 'project.json');
|
||||||
|
const manifestBefore = await readFile(manifestPath, 'utf8');
|
||||||
|
expect(JSON.parse(manifestBefore).id).toBe(projectId);
|
||||||
|
|
||||||
|
// Remove the external location: PUT empty locations so the location is dropped.
|
||||||
|
// This is an unmount/hide operation, not a destructive project delete.
|
||||||
|
const removeResp = await putProjectLocations([]);
|
||||||
|
expect(removeResp.status).toBe(200);
|
||||||
|
const removeBody = (await removeResp.json()) as {
|
||||||
|
locations: Array<{ id: string }>;
|
||||||
|
removedProjectIds?: string[];
|
||||||
|
};
|
||||||
|
// The response must include removedProjectIds with our project
|
||||||
|
expect(removeBody.removedProjectIds).toBeDefined();
|
||||||
|
expect(removeBody.removedProjectIds).toContain(projectId);
|
||||||
|
// Only the built-in default location should remain
|
||||||
|
expect(removeBody.locations).toHaveLength(1);
|
||||||
|
expect(removeBody.locations[0]!.id).toBe('default');
|
||||||
|
|
||||||
|
// The project should no longer appear in GET /api/projects
|
||||||
|
const listAfter = await fetch(`${baseUrl}/api/projects`);
|
||||||
|
expect(listAfter.status).toBe(200);
|
||||||
|
const afterBody = (await listAfter.json()) as { projects: Array<{ id: string }> };
|
||||||
|
expect(afterBody.projects.some((p) => p.id === projectId)).toBe(false);
|
||||||
|
|
||||||
|
// GET /api/projects/:id should return 404 while the location is unmounted.
|
||||||
|
const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`);
|
||||||
|
expect(detailResp.status).toBe(404);
|
||||||
|
|
||||||
|
// The on-disk project directory and manifest must still be intact
|
||||||
|
const manifestAfter = await readFile(manifestPath, 'utf8');
|
||||||
|
expect(JSON.parse(manifestAfter).id).toBe(projectId);
|
||||||
|
|
||||||
|
// Re-add the same base and scan: the existing DB row should be revealed,
|
||||||
|
// not recreated from only the manifest, so conversation history survives.
|
||||||
|
await putProjectLocations([{ id: locationId, name: 'Unreg External', path: extDir }]);
|
||||||
|
const scanResp = await fetch(`${baseUrl}/api/project-locations/scan`, { method: 'POST' });
|
||||||
|
expect(scanResp.status).toBe(200);
|
||||||
|
const scanBody = (await scanResp.json()) as { imported: Array<{ id: string }>; existing: string[] };
|
||||||
|
expect(scanBody.imported.some((p) => p.id === projectId)).toBe(false);
|
||||||
|
expect(scanBody.existing).toContain(projectId);
|
||||||
|
|
||||||
|
const listReadded = await fetch(`${baseUrl}/api/projects`);
|
||||||
|
expect(listReadded.status).toBe(200);
|
||||||
|
const readdedBody = (await listReadded.json()) as { projects: Array<{ id: string }> };
|
||||||
|
expect(readdedBody.projects.some((p) => p.id === projectId)).toBe(true);
|
||||||
|
|
||||||
|
const messagesResp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations/${createBody.conversationId}/messages`);
|
||||||
|
expect(messagesResp.status).toBe(200);
|
||||||
|
const messagesBody = (await messagesResp.json()) as { messages: Array<{ id: string; content: string }> };
|
||||||
|
expect(messagesBody.messages).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: messageId,
|
||||||
|
content: 'restore this conversation after location re-add',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
|
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
|
||||||
const previous = process.env.OD_SANDBOX_MODE;
|
const previous = process.env.OD_SANDBOX_MODE;
|
||||||
process.env.OD_SANDBOX_MODE = '1';
|
process.env.OD_SANDBOX_MODE = '1';
|
||||||
|
|
|
||||||
|
|
@ -1622,6 +1622,7 @@ function AppInner() {
|
||||||
daemonMediaProvidersFetchState={daemonMediaProvidersFetchState}
|
daemonMediaProvidersFetchState={daemonMediaProvidersFetchState}
|
||||||
mediaProvidersNotice={mediaProvidersNotice}
|
mediaProvidersNotice={mediaProvidersNotice}
|
||||||
onReloadMediaProviders={reloadMediaProvidersFromDaemon}
|
onReloadMediaProviders={reloadMediaProvidersFromDaemon}
|
||||||
|
onProjectsRefresh={refreshProjects}
|
||||||
onSkillsChanged={handleSkillsChanged}
|
onSkillsChanged={handleSkillsChanged}
|
||||||
onDesignSystemsChanged={handleDesignSystemsChanged}
|
onDesignSystemsChanged={handleDesignSystemsChanged}
|
||||||
providerModelsCache={providerModelsCache}
|
providerModelsCache={providerModelsCache}
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,7 @@ interface Props {
|
||||||
| 'appearance'
|
| 'appearance'
|
||||||
| 'notifications'
|
| 'notifications'
|
||||||
| 'pet'
|
| 'pet'
|
||||||
|
| 'projectLocations'
|
||||||
| 'library'
|
| 'library'
|
||||||
| 'about'
|
| 'about'
|
||||||
| 'memory'
|
| 'memory'
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ interface Props {
|
||||||
onOpenDesignSystem?: (id: string) => void;
|
onOpenDesignSystem?: (id: string) => void;
|
||||||
onDesignSystemsRefresh?: () => Promise<void> | void;
|
onDesignSystemsRefresh?: () => Promise<void> | void;
|
||||||
onPersistComposioKey: (composio: AppConfig['composio']) => Promise<void> | void;
|
onPersistComposioKey: (composio: AppConfig['composio']) => Promise<void> | void;
|
||||||
onOpenSettings: (section?: 'execution' | 'media' | 'composio' | 'orbit' | 'integrations' | 'mcpClient' | 'language' | 'appearance' | 'notifications' | 'pet' | 'library' | 'about' | 'memory' | 'designSystems') => void;
|
onOpenSettings: (section?: 'execution' | 'media' | 'composio' | 'orbit' | 'integrations' | 'mcpClient' | 'language' | 'appearance' | 'notifications' | 'pet' | 'projectLocations' | 'library' | 'about' | 'memory' | 'designSystems') => void;
|
||||||
onCompleteOnboarding: () => void;
|
onCompleteOnboarding: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -847,13 +847,15 @@ export function NewProjectPanel({
|
||||||
) : null}
|
) : null}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<input
|
<div className="newproj-name-row">
|
||||||
className="newproj-name"
|
<input
|
||||||
data-testid="new-project-name"
|
className="newproj-name"
|
||||||
placeholder={t('newproj.namePlaceholder')}
|
data-testid="new-project-name"
|
||||||
value={name}
|
placeholder={t('newproj.namePlaceholder')}
|
||||||
onChange={(e) => setName(e.target.value)}
|
value={name}
|
||||||
/>
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showDesignSystemPicker ? (
|
{showDesignSystemPicker ? (
|
||||||
<DesignSystemPicker
|
<DesignSystemPicker
|
||||||
|
|
|
||||||
239
apps/web/src/components/ProjectLocationsSection.tsx
Normal file
239
apps/web/src/components/ProjectLocationsSection.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
import type { ProjectLocation } from '@open-design/contracts';
|
||||||
|
import type { AppConfig } from '../types';
|
||||||
|
import {
|
||||||
|
fetchProjectLocations,
|
||||||
|
openProjectLocationFolderDialog,
|
||||||
|
scanProjectLocations,
|
||||||
|
updateProjectLocations,
|
||||||
|
} from '../state/project-locations';
|
||||||
|
import { useI18n } from '../i18n';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cfg: AppConfig;
|
||||||
|
setCfg: Dispatch<SetStateAction<AppConfig>>;
|
||||||
|
onProjectsRefresh?: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DraftLocation {
|
||||||
|
id?: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationLabel(locationPath: string): string {
|
||||||
|
return locationPath.split(/[\\/]/).filter(Boolean).pop() || locationPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function externalLocations(locations: ProjectLocation[]): DraftLocation[] {
|
||||||
|
return locations
|
||||||
|
.filter((location) => !location.builtIn)
|
||||||
|
.map((location) => ({ id: location.id, path: location.path }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toConfigLocations(locations: ProjectLocation[]): NonNullable<AppConfig['projectLocations']> {
|
||||||
|
return locations
|
||||||
|
.filter((location) => !location.builtIn)
|
||||||
|
.map((location) => ({ id: location.id, name: location.name, path: location.path }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectLocationsSection({ cfg, setCfg, onProjectsRefresh }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [locations, setLocations] = useState<ProjectLocation[]>([]);
|
||||||
|
const [drafts, setDrafts] = useState<DraftLocation[]>(cfg.projectLocations ?? []);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const draftsRef = useRef<DraftLocation[]>(drafts);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
draftsRef.current = drafts;
|
||||||
|
}, [drafts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
fetchProjectLocations()
|
||||||
|
.then((next) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLocations(next);
|
||||||
|
setDrafts(externalLocations(next));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [setCfg]);
|
||||||
|
|
||||||
|
const builtIn = useMemo(
|
||||||
|
() => locations.find((location) => location.builtIn),
|
||||||
|
[locations],
|
||||||
|
);
|
||||||
|
const effectiveDefaultLocationId = useMemo(() => {
|
||||||
|
const configured = cfg.defaultProjectLocationId ?? 'default';
|
||||||
|
return locations.some((location) => location.id === configured) ? configured : 'default';
|
||||||
|
}, [cfg.defaultProjectLocationId, locations]);
|
||||||
|
|
||||||
|
function defaultControlLabel(locationId: string): string {
|
||||||
|
return effectiveDefaultLocationId === locationId
|
||||||
|
? t('settings.projectLocationsDefaultBadge')
|
||||||
|
: t('settings.projectLocationsMakeDefault');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDefaultLocationChange(locationId: string) {
|
||||||
|
setError(null);
|
||||||
|
setStatus(t('settings.projectLocationsDefaultSaved'));
|
||||||
|
setCfg((current) => ({ ...current, defaultProjectLocationId: locationId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(nextDrafts: DraftLocation[]) {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setStatus(null);
|
||||||
|
try {
|
||||||
|
const saved = await updateProjectLocations(
|
||||||
|
nextDrafts.filter((location) => location.path.trim()),
|
||||||
|
);
|
||||||
|
if (!saved) {
|
||||||
|
setError(t('settings.projectLocationsSaveError'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setLocations(saved);
|
||||||
|
const external = externalLocations(saved);
|
||||||
|
setDrafts(external);
|
||||||
|
setCfg((current) => {
|
||||||
|
const configuredDefault = current.defaultProjectLocationId ?? 'default';
|
||||||
|
const nextDefault = saved.some((location) => location.id === configuredDefault)
|
||||||
|
? configuredDefault
|
||||||
|
: 'default';
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
projectLocations: toConfigLocations(saved),
|
||||||
|
defaultProjectLocationId: nextDefault,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setStatus(t('settings.projectLocationsSaved'));
|
||||||
|
void onProjectsRefresh?.();
|
||||||
|
return external;
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runScan() {
|
||||||
|
const result = await scanProjectLocations();
|
||||||
|
if (!result) {
|
||||||
|
setError(t('settings.projectLocationsScanError'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setStatus(t('settings.projectLocationsScanComplete', {
|
||||||
|
imported: result.imported.length,
|
||||||
|
existing: result.existing.length,
|
||||||
|
}));
|
||||||
|
void onProjectsRefresh?.();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddFolder() {
|
||||||
|
setError(null);
|
||||||
|
setStatus(null);
|
||||||
|
const selected = await openProjectLocationFolderDialog();
|
||||||
|
if (!selected) {
|
||||||
|
setStatus(t('settings.projectLocationsNoFolderSelected'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draftsRef.current.some((draft) => draft.path === selected)) {
|
||||||
|
setStatus(t('settings.projectLocationsDuplicate'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previous = draftsRef.current;
|
||||||
|
const next = [...previous, { path: selected }];
|
||||||
|
setDrafts(next);
|
||||||
|
const saved = await save(next);
|
||||||
|
if (!saved) setDrafts(previous);
|
||||||
|
else await runScan();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDraft(index: number) {
|
||||||
|
const previous = draftsRef.current;
|
||||||
|
const next = previous.filter((_, i) => i !== index);
|
||||||
|
setDrafts(next);
|
||||||
|
const saved = await save(next);
|
||||||
|
if (!saved) setDrafts(previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="settings-section settings-section-card project-locations-section">
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h3>{t('settings.projectLocations')}</h3>
|
||||||
|
<p className="hint">{t('settings.projectLocationsDescription')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{builtIn ? (
|
||||||
|
<div className={`project-location-card is-built-in${effectiveDefaultLocationId === builtIn.id ? ' is-default' : ''}`}>
|
||||||
|
<div>
|
||||||
|
<strong>{t('newproj.locationDefault')}</strong>
|
||||||
|
<code>{builtIn.path}</code>
|
||||||
|
</div>
|
||||||
|
<label className="project-location-default-control">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="project-location-default"
|
||||||
|
checked={effectiveDefaultLocationId === builtIn.id}
|
||||||
|
onChange={() => handleDefaultLocationChange(builtIn.id)}
|
||||||
|
/>
|
||||||
|
<span>{defaultControlLabel(builtIn.id)}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="project-location-list">
|
||||||
|
{drafts.map((draft, index) => (
|
||||||
|
<div
|
||||||
|
className={`project-location-edit${draft.id && effectiveDefaultLocationId === draft.id ? ' is-default' : ''}`}
|
||||||
|
key={`${draft.id ?? 'new'}-${index}`}
|
||||||
|
>
|
||||||
|
<div className="project-location-edit-main">
|
||||||
|
<strong>{locationLabel(draft.path)}</strong>
|
||||||
|
<code>{draft.path}</code>
|
||||||
|
<small>{t('settings.projectLocationsWorkBaseMeta')}</small>
|
||||||
|
</div>
|
||||||
|
{draft.id ? (
|
||||||
|
<label className="project-location-default-control">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="project-location-default"
|
||||||
|
checked={effectiveDefaultLocationId === draft.id}
|
||||||
|
onChange={() => handleDefaultLocationChange(draft.id!)}
|
||||||
|
/>
|
||||||
|
<span>{defaultControlLabel(draft.id)}</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<button type="button" className="icon-btn danger" onClick={() => removeDraft(index)} disabled={saving}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn project-location-add"
|
||||||
|
onClick={handleAddFolder}
|
||||||
|
disabled={loading || saving}
|
||||||
|
>
|
||||||
|
<Icon name="plus" size={12} />
|
||||||
|
{t('settings.projectLocationsAddFolder')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{status ? <p className="settings-rescan-status">{status}</p> : null}
|
||||||
|
{error ? <p className="settings-rescan-status error">{error}</p> : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -94,6 +94,7 @@ import { McpClientSection } from './McpClientSection';
|
||||||
import { SkillsSection } from './SkillsSection';
|
import { SkillsSection } from './SkillsSection';
|
||||||
import { DesignSystemsSection } from './DesignSystemsSection';
|
import { DesignSystemsSection } from './DesignSystemsSection';
|
||||||
import { PrivacySection } from './PrivacySection';
|
import { PrivacySection } from './PrivacySection';
|
||||||
|
import { ProjectLocationsSection } from './ProjectLocationsSection';
|
||||||
import { RoutinesSection } from './RoutinesSection';
|
import { RoutinesSection } from './RoutinesSection';
|
||||||
import { ConnectorsBrowser } from './ConnectorsBrowser';
|
import { ConnectorsBrowser } from './ConnectorsBrowser';
|
||||||
import { MemoryModelInline } from './MemoryModelInline';
|
import { MemoryModelInline } from './MemoryModelInline';
|
||||||
|
|
@ -135,6 +136,7 @@ export type SettingsSection =
|
||||||
| 'pet'
|
| 'pet'
|
||||||
| 'skills'
|
| 'skills'
|
||||||
| 'designSystems'
|
| 'designSystems'
|
||||||
|
| 'projectLocations'
|
||||||
| 'memory'
|
| 'memory'
|
||||||
| 'privacy'
|
| 'privacy'
|
||||||
// 'library' is consumed by the EntryShell library route — App opens it
|
// 'library' is consumed by the EntryShell library route — App opens it
|
||||||
|
|
@ -194,6 +196,7 @@ interface Props {
|
||||||
daemonMediaProvidersFetchState?: 'idle' | 'ok' | 'error';
|
daemonMediaProvidersFetchState?: 'idle' | 'ok' | 'error';
|
||||||
mediaProvidersNotice?: string | null;
|
mediaProvidersNotice?: string | null;
|
||||||
onReloadMediaProviders?: () => Promise<AppConfig['mediaProviders'] | null>;
|
onReloadMediaProviders?: () => Promise<AppConfig['mediaProviders'] | null>;
|
||||||
|
onProjectsRefresh?: () => Promise<void> | void;
|
||||||
/**
|
/**
|
||||||
* Notified by Settings → Skills after a successful skill registry
|
* Notified by Settings → Skills after a successful skill registry
|
||||||
* mutation (create / edit / delete). App.tsx uses this to drop preview
|
* mutation (create / edit / delete). App.tsx uses this to drop preview
|
||||||
|
|
@ -835,6 +838,7 @@ export function SettingsDialog({
|
||||||
daemonMediaProvidersFetchState = 'idle',
|
daemonMediaProvidersFetchState = 'idle',
|
||||||
mediaProvidersNotice,
|
mediaProvidersNotice,
|
||||||
onReloadMediaProviders,
|
onReloadMediaProviders,
|
||||||
|
onProjectsRefresh,
|
||||||
onSkillsChanged,
|
onSkillsChanged,
|
||||||
onDesignSystemsChanged,
|
onDesignSystemsChanged,
|
||||||
providerModelsCache: sharedProviderModelsCache,
|
providerModelsCache: sharedProviderModelsCache,
|
||||||
|
|
@ -2034,6 +2038,10 @@ export function SettingsDialog({
|
||||||
title: t('settings.designSystems'),
|
title: t('settings.designSystems'),
|
||||||
subtitle: t('settings.designSystemsHint'),
|
subtitle: t('settings.designSystemsHint'),
|
||||||
},
|
},
|
||||||
|
projectLocations: {
|
||||||
|
title: t('settings.projectLocations'),
|
||||||
|
subtitle: t('settings.projectLocationsHint'),
|
||||||
|
},
|
||||||
memory: { title: t('settings.memory'), subtitle: t('settings.memoryHint') },
|
memory: { title: t('settings.memory'), subtitle: t('settings.memoryHint') },
|
||||||
// 'library' is opened via EntryShell route — SettingsDialog doesn't
|
// 'library' is opened via EntryShell route — SettingsDialog doesn't
|
||||||
// render it but SettingsSection must accept the token (see type def).
|
// render it but SettingsSection must accept the token (see type def).
|
||||||
|
|
@ -2465,6 +2473,17 @@ export function SettingsDialog({
|
||||||
<small>{t('settings.designSystemsHint')}</small>
|
<small>{t('settings.designSystemsHint')}</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`settings-nav-item${activeSection === 'projectLocations' ? ' active' : ''}`}
|
||||||
|
onClick={() => setActiveSection('projectLocations')}
|
||||||
|
>
|
||||||
|
<Icon name="folder" size={18} />
|
||||||
|
<span>
|
||||||
|
<strong>{t('settings.projectLocations')}</strong>
|
||||||
|
<small>{t('settings.projectLocationsHint')}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`settings-nav-item${activeSection === 'privacy' ? ' active' : ''}`}
|
className={`settings-nav-item${activeSection === 'privacy' ? ' active' : ''}`}
|
||||||
|
|
@ -3664,6 +3683,10 @@ export function SettingsDialog({
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{activeSection === 'projectLocations' ? (
|
||||||
|
<ProjectLocationsSection cfg={cfg} setCfg={setCfg} onProjectsRefresh={onProjectsRefresh} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
{activeSection === 'instructions' ? (
|
{activeSection === 'instructions' ? (
|
||||||
<section className="settings-section settings-section-card instructions-rules-section">
|
<section className="settings-section settings-section-card instructions-rules-section">
|
||||||
<div className="memory-field-block instructions-rules-card">
|
<div className="memory-field-block instructions-rules-card">
|
||||||
|
|
|
||||||
|
|
@ -566,6 +566,9 @@ export const ar: Dict = {
|
||||||
'newproj.fileSingular': 'ملف',
|
'newproj.fileSingular': 'ملف',
|
||||||
'newproj.filePlural': 'ملفات',
|
'newproj.filePlural': 'ملفات',
|
||||||
'newproj.create': 'إنشاء',
|
'newproj.create': 'إنشاء',
|
||||||
|
'newproj.locationLabel': 'حفظ في',
|
||||||
|
'newproj.locationDefault': 'مشاريع Open Design',
|
||||||
|
'newproj.locationExternalBase': 'قاعدة خارجية',
|
||||||
'newproj.createFromTemplate': 'إنشاء من قالب',
|
'newproj.createFromTemplate': 'إنشاء من قالب',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'احفظ مشروعاً كقالب أولاً (قائمة المشاركة داخل أي مشروع).',
|
'احفظ مشروعاً كقالب أولاً (قائمة المشاركة داخل أي مشروع).',
|
||||||
|
|
@ -1552,6 +1555,20 @@ export const ar: Dict = {
|
||||||
'settings.designSystemsCategory': 'الفئة',
|
'settings.designSystemsCategory': 'الفئة',
|
||||||
'settings.designSystemsAllCategories': 'كل الفئات',
|
'settings.designSystemsAllCategories': 'كل الفئات',
|
||||||
'settings.designSystemsShowInHomeGallery': 'إظهار في معرض الصفحة الرئيسية',
|
'settings.designSystemsShowInHomeGallery': 'إظهار في معرض الصفحة الرئيسية',
|
||||||
|
'settings.projectLocations': 'مواقع المشاريع',
|
||||||
|
'settings.projectLocationsHint': 'جذور تخزين مساحات العمل',
|
||||||
|
'settings.projectLocationsDescription': 'أضف قواعد عمل يمكن أن تحتوي على عدة مجلدات مشاريع Open Design. تُحفظ المشاريع الجديدة كمجلد داخل القاعدة المحددة.',
|
||||||
|
'settings.projectLocationsSaveError': 'تعذّر حفظ مواقع المشاريع. تحقق من أن كل مسار مجلد يمكن الوصول إليه.',
|
||||||
|
'settings.projectLocationsSaved': 'تم حفظ مواقع المشاريع.',
|
||||||
|
'settings.projectLocationsScanError': 'تعذّر فحص مواقع المشاريع.',
|
||||||
|
'settings.projectLocationsScanComplete': 'اكتمل الفحص: تم استيراد {imported}، و{existing} مسجلة مسبقًا.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'لم يتم اختيار مجلد.',
|
||||||
|
'settings.projectLocationsDuplicate': 'تمت إضافة قاعدة العمل هذه بالفعل.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'قاعدة عمل · يتم إنشاء المشاريع هنا كمجلدات فرعية',
|
||||||
|
'settings.projectLocationsAddFolder': 'إضافة مجلد…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'الموقع الافتراضي',
|
||||||
|
'settings.projectLocationsMakeDefault': 'تعيين كافتراضي',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'تم تحديث موقع المشروع الافتراضي.',
|
||||||
'settings.librarySkills': 'المهارات',
|
'settings.librarySkills': 'المهارات',
|
||||||
'settings.libraryDesignSystems': 'أنظمة التصميم',
|
'settings.libraryDesignSystems': 'أنظمة التصميم',
|
||||||
'settings.librarySearch': 'بحث...',
|
'settings.librarySearch': 'بحث...',
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,9 @@ export const de: Dict = {
|
||||||
'newproj.fileSingular': 'Datei',
|
'newproj.fileSingular': 'Datei',
|
||||||
'newproj.filePlural': 'Dateien',
|
'newproj.filePlural': 'Dateien',
|
||||||
'newproj.create': 'Erstellen',
|
'newproj.create': 'Erstellen',
|
||||||
|
'newproj.locationLabel': 'Speichern unter',
|
||||||
|
'newproj.locationDefault': 'Open Design-Projekte',
|
||||||
|
'newproj.locationExternalBase': 'Externe Basis',
|
||||||
'newproj.createFromTemplate': 'Aus Template erstellen',
|
'newproj.createFromTemplate': 'Aus Template erstellen',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'Speichern Sie zuerst ein Projekt als Template (Teilen-Menü in einem beliebigen Projekt).',
|
'Speichern Sie zuerst ein Projekt als Template (Teilen-Menü in einem beliebigen Projekt).',
|
||||||
|
|
@ -1490,6 +1493,20 @@ export const de: Dict = {
|
||||||
'settings.designSystemsCategory': 'Kategorie',
|
'settings.designSystemsCategory': 'Kategorie',
|
||||||
'settings.designSystemsAllCategories': 'Alle Kategorien',
|
'settings.designSystemsAllCategories': 'Alle Kategorien',
|
||||||
'settings.designSystemsShowInHomeGallery': 'In Home-Galerie anzeigen',
|
'settings.designSystemsShowInHomeGallery': 'In Home-Galerie anzeigen',
|
||||||
|
'settings.projectLocations': 'Projektorte',
|
||||||
|
'settings.projectLocationsHint': 'Workspace-Speicherorte',
|
||||||
|
'settings.projectLocationsDescription': 'Füge Arbeitsbasen hinzu, die mehrere Open Design-Projektordner enthalten können. Neue Projekte werden als Ordner in der ausgewählten Basis gespeichert.',
|
||||||
|
'settings.projectLocationsSaveError': 'Projektorte konnten nicht gespeichert werden. Prüfe, ob jeder Pfad ein zugänglicher Ordner ist.',
|
||||||
|
'settings.projectLocationsSaved': 'Projektorte gespeichert.',
|
||||||
|
'settings.projectLocationsScanError': 'Projektorte konnten nicht gescannt werden.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Scan abgeschlossen: {imported} importiert, {existing} bereits registriert.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Kein Ordner ausgewählt.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Diese Arbeitsbasis wurde bereits hinzugefügt.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Arbeitsbasis · Projekte werden hier als Unterordner erstellt',
|
||||||
|
'settings.projectLocationsAddFolder': 'Ordner hinzufügen…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Standardort',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Als Standard festlegen',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Standard-Projektort aktualisiert.',
|
||||||
'settings.librarySkills': 'Fähigkeiten',
|
'settings.librarySkills': 'Fähigkeiten',
|
||||||
'settings.libraryDesignSystems': 'Designsysteme',
|
'settings.libraryDesignSystems': 'Designsysteme',
|
||||||
'settings.librarySearch': 'Suchen...',
|
'settings.librarySearch': 'Suchen...',
|
||||||
|
|
|
||||||
|
|
@ -1157,6 +1157,9 @@ export const en: Dict = {
|
||||||
'newproj.fileSingular': 'file',
|
'newproj.fileSingular': 'file',
|
||||||
'newproj.filePlural': 'files',
|
'newproj.filePlural': 'files',
|
||||||
'newproj.create': 'Create',
|
'newproj.create': 'Create',
|
||||||
|
'newproj.locationLabel': 'Save to',
|
||||||
|
'newproj.locationDefault': 'Open Design projects',
|
||||||
|
'newproj.locationExternalBase': 'External base',
|
||||||
'newproj.createLiveArtifact': 'Create live artifact',
|
'newproj.createLiveArtifact': 'Create live artifact',
|
||||||
'newproj.createFromTemplate': 'Create from template',
|
'newproj.createFromTemplate': 'Create from template',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
|
|
@ -2349,6 +2352,20 @@ export const en: Dict = {
|
||||||
'settings.designSystemsCategory': 'Category',
|
'settings.designSystemsCategory': 'Category',
|
||||||
'settings.designSystemsAllCategories': 'All categories',
|
'settings.designSystemsAllCategories': 'All categories',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Show in home gallery',
|
'settings.designSystemsShowInHomeGallery': 'Show in home gallery',
|
||||||
|
'settings.projectLocations': 'Project locations',
|
||||||
|
'settings.projectLocationsHint': 'Workspace storage roots',
|
||||||
|
'settings.projectLocationsDescription': 'Add work bases that can contain multiple Open Design project folders. New projects are saved as one folder inside the selected base.',
|
||||||
|
'settings.projectLocationsSaveError': 'Could not save project locations. Check that each path is an accessible folder.',
|
||||||
|
'settings.projectLocationsSaved': 'Project locations saved.',
|
||||||
|
'settings.projectLocationsScanError': 'Could not scan project locations.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Scan complete: {imported} imported, {existing} already registered.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'No folder selected.',
|
||||||
|
'settings.projectLocationsDuplicate': 'That work base is already added.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Work base · projects are created as subfolders here',
|
||||||
|
'settings.projectLocationsAddFolder': 'Add folder…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Default location',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Make default',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Default project location updated.',
|
||||||
'settings.librarySkills': 'Skills',
|
'settings.librarySkills': 'Skills',
|
||||||
'settings.libraryDesignSystems': 'Design Systems',
|
'settings.libraryDesignSystems': 'Design Systems',
|
||||||
'settings.librarySearch': 'Search...',
|
'settings.librarySearch': 'Search...',
|
||||||
|
|
|
||||||
|
|
@ -464,6 +464,9 @@ export const esES: Dict = {
|
||||||
'newproj.fileSingular': 'archivo',
|
'newproj.fileSingular': 'archivo',
|
||||||
'newproj.filePlural': 'archivos',
|
'newproj.filePlural': 'archivos',
|
||||||
'newproj.create': 'Crear',
|
'newproj.create': 'Crear',
|
||||||
|
'newproj.locationLabel': 'Guardar en',
|
||||||
|
'newproj.locationDefault': 'Proyectos de Open Design',
|
||||||
|
'newproj.locationExternalBase': 'Base externa',
|
||||||
'newproj.createFromTemplate': 'Crear desde plantilla',
|
'newproj.createFromTemplate': 'Crear desde plantilla',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'Guarda primero un proyecto como plantilla (menú Compartir dentro de cualquier proyecto).',
|
'Guarda primero un proyecto como plantilla (menú Compartir dentro de cualquier proyecto).',
|
||||||
|
|
@ -1441,6 +1444,20 @@ export const esES: Dict = {
|
||||||
'settings.designSystemsCategory': 'Categoría',
|
'settings.designSystemsCategory': 'Categoría',
|
||||||
'settings.designSystemsAllCategories': 'Todas las categorías',
|
'settings.designSystemsAllCategories': 'Todas las categorías',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Mostrar en la galería de inicio',
|
'settings.designSystemsShowInHomeGallery': 'Mostrar en la galería de inicio',
|
||||||
|
'settings.projectLocations': 'Ubicaciones de proyectos',
|
||||||
|
'settings.projectLocationsHint': 'Raíces de almacenamiento del espacio de trabajo',
|
||||||
|
'settings.projectLocationsDescription': 'Añade bases de trabajo que pueden contener varias carpetas de proyectos de Open Design. Los proyectos nuevos se guardan como una carpeta dentro de la base seleccionada.',
|
||||||
|
'settings.projectLocationsSaveError': 'No se pudieron guardar las ubicaciones de proyectos. Comprueba que cada ruta sea una carpeta accesible.',
|
||||||
|
'settings.projectLocationsSaved': 'Ubicaciones de proyectos guardadas.',
|
||||||
|
'settings.projectLocationsScanError': 'No se pudieron escanear las ubicaciones de proyectos.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Escaneo completado: {imported} importados, {existing} ya registrados.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'No se seleccionó ninguna carpeta.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Esa base de trabajo ya está añadida.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Base de trabajo · los proyectos se crean aquí como subcarpetas',
|
||||||
|
'settings.projectLocationsAddFolder': 'Añadir carpeta…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Ubicación predeterminada',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Hacer predeterminada',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Ubicación de proyecto predeterminada actualizada.',
|
||||||
'settings.librarySkills': 'Habilidades',
|
'settings.librarySkills': 'Habilidades',
|
||||||
'settings.libraryDesignSystems': 'Sistemas de diseño',
|
'settings.libraryDesignSystems': 'Sistemas de diseño',
|
||||||
'settings.librarySearch': 'Buscar...',
|
'settings.librarySearch': 'Buscar...',
|
||||||
|
|
|
||||||
|
|
@ -578,6 +578,9 @@ export const fa: Dict = {
|
||||||
'newproj.fileSingular': 'فایل',
|
'newproj.fileSingular': 'فایل',
|
||||||
'newproj.filePlural': 'فایل',
|
'newproj.filePlural': 'فایل',
|
||||||
'newproj.create': 'ایجاد',
|
'newproj.create': 'ایجاد',
|
||||||
|
'newproj.locationLabel': 'ذخیره در',
|
||||||
|
'newproj.locationDefault': 'پروژههای Open Design',
|
||||||
|
'newproj.locationExternalBase': 'پایهٔ خارجی',
|
||||||
'newproj.createLiveArtifact': 'ایجاد مصنوع زنده',
|
'newproj.createLiveArtifact': 'ایجاد مصنوع زنده',
|
||||||
'newproj.createFromTemplate': 'ایجاد از قالب',
|
'newproj.createFromTemplate': 'ایجاد از قالب',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
|
|
@ -1595,6 +1598,20 @@ export const fa: Dict = {
|
||||||
'settings.designSystemsCategory': 'دستهبندی',
|
'settings.designSystemsCategory': 'دستهبندی',
|
||||||
'settings.designSystemsAllCategories': 'همه دستهبندیها',
|
'settings.designSystemsAllCategories': 'همه دستهبندیها',
|
||||||
'settings.designSystemsShowInHomeGallery': 'نمایش در گالری خانه',
|
'settings.designSystemsShowInHomeGallery': 'نمایش در گالری خانه',
|
||||||
|
'settings.projectLocations': 'مکانهای پروژه',
|
||||||
|
'settings.projectLocationsHint': 'ریشههای ذخیرهسازی فضای کاری',
|
||||||
|
'settings.projectLocationsDescription': 'پایههای کاری اضافه کنید که میتوانند چند پوشهٔ پروژهٔ Open Design را در خود داشته باشند. پروژههای جدید بهصورت یک پوشه داخل پایهٔ انتخابشده ذخیره میشوند.',
|
||||||
|
'settings.projectLocationsSaveError': 'ذخیرهٔ مکانهای پروژه ممکن نشد. بررسی کنید هر مسیر یک پوشهٔ قابل دسترسی باشد.',
|
||||||
|
'settings.projectLocationsSaved': 'مکانهای پروژه ذخیره شد.',
|
||||||
|
'settings.projectLocationsScanError': 'اسکن مکانهای پروژه ممکن نشد.',
|
||||||
|
'settings.projectLocationsScanComplete': 'اسکن کامل شد: {imported} وارد شد، {existing} از قبل ثبت شده بود.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'پوشهای انتخاب نشد.',
|
||||||
|
'settings.projectLocationsDuplicate': 'این پایهٔ کاری قبلاً اضافه شده است.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'پایهٔ کاری · پروژهها اینجا بهصورت زیرپوشه ساخته میشوند',
|
||||||
|
'settings.projectLocationsAddFolder': 'افزودن پوشه…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'مکان پیشفرض',
|
||||||
|
'settings.projectLocationsMakeDefault': 'تنظیم بهعنوان پیشفرض',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'مکان پیشفرض پروژه بهروزرسانی شد.',
|
||||||
'settings.librarySkills': 'مهارتها',
|
'settings.librarySkills': 'مهارتها',
|
||||||
'settings.libraryDesignSystems': 'سیستمهای طراحی',
|
'settings.libraryDesignSystems': 'سیستمهای طراحی',
|
||||||
'settings.librarySearch': 'جستجو...',
|
'settings.librarySearch': 'جستجو...',
|
||||||
|
|
|
||||||
|
|
@ -1101,6 +1101,9 @@ export const fr: Dict = {
|
||||||
'newproj.fileSingular': 'fichier',
|
'newproj.fileSingular': 'fichier',
|
||||||
'newproj.filePlural': 'fichiers',
|
'newproj.filePlural': 'fichiers',
|
||||||
'newproj.create': 'Créer',
|
'newproj.create': 'Créer',
|
||||||
|
'newproj.locationLabel': 'Enregistrer dans',
|
||||||
|
'newproj.locationDefault': 'Projets Open Design',
|
||||||
|
'newproj.locationExternalBase': 'Base externe',
|
||||||
'newproj.createLiveArtifact': 'Créer un artefact dynamique',
|
'newproj.createLiveArtifact': 'Créer un artefact dynamique',
|
||||||
'newproj.createFromTemplate': 'Créer depuis le modèle',
|
'newproj.createFromTemplate': 'Créer depuis le modèle',
|
||||||
'newproj.createDisabledTitle': 'Enregistrez d\'abord un projet comme modèle (menu Partager dans un projet).',
|
'newproj.createDisabledTitle': 'Enregistrez d\'abord un projet comme modèle (menu Partager dans un projet).',
|
||||||
|
|
@ -2214,6 +2217,20 @@ export const fr: Dict = {
|
||||||
'settings.designSystemsCategory': 'Catégorie',
|
'settings.designSystemsCategory': 'Catégorie',
|
||||||
'settings.designSystemsAllCategories': 'Toutes les catégories',
|
'settings.designSystemsAllCategories': 'Toutes les catégories',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Afficher dans la galerie d’accueil',
|
'settings.designSystemsShowInHomeGallery': 'Afficher dans la galerie d’accueil',
|
||||||
|
'settings.projectLocations': 'Emplacements de projets',
|
||||||
|
'settings.projectLocationsHint': 'Racines de stockage des espaces de travail',
|
||||||
|
'settings.projectLocationsDescription': 'Ajoutez des bases de travail pouvant contenir plusieurs dossiers de projets Open Design. Les nouveaux projets sont enregistrés comme un dossier dans la base sélectionnée.',
|
||||||
|
'settings.projectLocationsSaveError': 'Impossible d’enregistrer les emplacements de projets. Vérifiez que chaque chemin est un dossier accessible.',
|
||||||
|
'settings.projectLocationsSaved': 'Emplacements de projets enregistrés.',
|
||||||
|
'settings.projectLocationsScanError': 'Impossible d’analyser les emplacements de projets.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Analyse terminée : {imported} importé(s), {existing} déjà enregistré(s).',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Aucun dossier sélectionné.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Cette base de travail est déjà ajoutée.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Base de travail · les projets sont créés ici comme sous-dossiers',
|
||||||
|
'settings.projectLocationsAddFolder': 'Ajouter un dossier…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Emplacement par défaut',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Définir par défaut',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Emplacement de projet par défaut mis à jour.',
|
||||||
'settings.librarySkills': 'Compétences',
|
'settings.librarySkills': 'Compétences',
|
||||||
'settings.libraryDesignSystems': 'Systèmes de design',
|
'settings.libraryDesignSystems': 'Systèmes de design',
|
||||||
'settings.librarySearch': 'Rechercher...',
|
'settings.librarySearch': 'Rechercher...',
|
||||||
|
|
|
||||||
|
|
@ -566,6 +566,9 @@ export const hu: Dict = {
|
||||||
'newproj.fileSingular': 'fájl',
|
'newproj.fileSingular': 'fájl',
|
||||||
'newproj.filePlural': 'fájl',
|
'newproj.filePlural': 'fájl',
|
||||||
'newproj.create': 'Létrehozás',
|
'newproj.create': 'Létrehozás',
|
||||||
|
'newproj.locationLabel': 'Mentés ide',
|
||||||
|
'newproj.locationDefault': 'Open Design projektek',
|
||||||
|
'newproj.locationExternalBase': 'Külső bázis',
|
||||||
'newproj.createFromTemplate': 'Létrehozás sablonból',
|
'newproj.createFromTemplate': 'Létrehozás sablonból',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'Először ments el egy projektet sablonként (bármely projekt Megosztás menüjéből).',
|
'Először ments el egy projektet sablonként (bármely projekt Megosztás menüjéből).',
|
||||||
|
|
@ -1562,6 +1565,20 @@ export const hu: Dict = {
|
||||||
'settings.designSystemsCategory': 'Kategória',
|
'settings.designSystemsCategory': 'Kategória',
|
||||||
'settings.designSystemsAllCategories': 'Minden kategória',
|
'settings.designSystemsAllCategories': 'Minden kategória',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Megjelenítés a kezdő galériában',
|
'settings.designSystemsShowInHomeGallery': 'Megjelenítés a kezdő galériában',
|
||||||
|
'settings.projectLocations': 'Projekt helyek',
|
||||||
|
'settings.projectLocationsHint': 'Munkaterület tárolási gyökerek',
|
||||||
|
'settings.projectLocationsDescription': 'Adj hozzá munkabázisokat, amelyek több Open Design projektmappát is tartalmazhatnak. Az új projektek mappaként jönnek létre a kiválasztott bázison belül.',
|
||||||
|
'settings.projectLocationsSaveError': 'Nem sikerült menteni a projekt helyeket. Ellenőrizd, hogy minden útvonal elérhető mappa-e.',
|
||||||
|
'settings.projectLocationsSaved': 'Projekt helyek mentve.',
|
||||||
|
'settings.projectLocationsScanError': 'Nem sikerült beolvasni a projekt helyeket.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Beolvasás kész: {imported} importálva, {existing} már regisztrálva.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Nincs kiválasztott mappa.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Ez a munkabázis már hozzá van adva.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Munkabázis · a projektek itt almappaként jönnek létre',
|
||||||
|
'settings.projectLocationsAddFolder': 'Mappa hozzáadása…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Alapértelmezett hely',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Legyen alapértelmezett',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Az alapértelmezett projekt hely frissítve.',
|
||||||
'settings.librarySkills': 'Készségek',
|
'settings.librarySkills': 'Készségek',
|
||||||
'settings.libraryDesignSystems': 'Tervezőrendszerek',
|
'settings.libraryDesignSystems': 'Tervezőrendszerek',
|
||||||
'settings.librarySearch': 'Keresés...',
|
'settings.librarySearch': 'Keresés...',
|
||||||
|
|
|
||||||
|
|
@ -672,6 +672,9 @@ export const id: Dict = {
|
||||||
'newproj.fileSingular': 'berkas',
|
'newproj.fileSingular': 'berkas',
|
||||||
'newproj.filePlural': 'berkas',
|
'newproj.filePlural': 'berkas',
|
||||||
'newproj.create': 'Buat',
|
'newproj.create': 'Buat',
|
||||||
|
'newproj.locationLabel': 'Simpan ke',
|
||||||
|
'newproj.locationDefault': 'Proyek Open Design',
|
||||||
|
'newproj.locationExternalBase': 'Basis eksternal',
|
||||||
'newproj.createLiveArtifact': 'Buat live artifact',
|
'newproj.createLiveArtifact': 'Buat live artifact',
|
||||||
'newproj.createFromTemplate': 'Buat dari templat',
|
'newproj.createFromTemplate': 'Buat dari templat',
|
||||||
'newproj.createDisabledTitle': 'Simpan proyek sebagai templat dulu.',
|
'newproj.createDisabledTitle': 'Simpan proyek sebagai templat dulu.',
|
||||||
|
|
@ -1700,6 +1703,20 @@ export const id: Dict = {
|
||||||
'settings.designSystemsCategory': 'Kategori',
|
'settings.designSystemsCategory': 'Kategori',
|
||||||
'settings.designSystemsAllCategories': 'Semua kategori',
|
'settings.designSystemsAllCategories': 'Semua kategori',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Tampilkan di galeri beranda',
|
'settings.designSystemsShowInHomeGallery': 'Tampilkan di galeri beranda',
|
||||||
|
'settings.projectLocations': 'Lokasi proyek',
|
||||||
|
'settings.projectLocationsHint': 'Root penyimpanan workspace',
|
||||||
|
'settings.projectLocationsDescription': 'Tambahkan basis kerja yang dapat berisi beberapa folder proyek Open Design. Proyek baru disimpan sebagai folder di dalam basis yang dipilih.',
|
||||||
|
'settings.projectLocationsSaveError': 'Tidak dapat menyimpan lokasi proyek. Pastikan setiap path adalah folder yang dapat diakses.',
|
||||||
|
'settings.projectLocationsSaved': 'Lokasi proyek disimpan.',
|
||||||
|
'settings.projectLocationsScanError': 'Tidak dapat memindai lokasi proyek.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Pemindaian selesai: {imported} diimpor, {existing} sudah terdaftar.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Tidak ada folder yang dipilih.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Basis kerja itu sudah ditambahkan.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Basis kerja · proyek dibuat di sini sebagai subfolder',
|
||||||
|
'settings.projectLocationsAddFolder': 'Tambah folder…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Lokasi default',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Jadikan default',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Lokasi proyek default diperbarui.',
|
||||||
'settings.librarySkills': 'Skill',
|
'settings.librarySkills': 'Skill',
|
||||||
'settings.libraryDesignSystems': 'Sistem desain',
|
'settings.libraryDesignSystems': 'Sistem desain',
|
||||||
'settings.librarySearch': 'Cari...',
|
'settings.librarySearch': 'Cari...',
|
||||||
|
|
|
||||||
|
|
@ -539,6 +539,9 @@ export const it: Dict = {
|
||||||
'newproj.fileSingular': 'file',
|
'newproj.fileSingular': 'file',
|
||||||
'newproj.filePlural': 'file',
|
'newproj.filePlural': 'file',
|
||||||
'newproj.create': 'Crea',
|
'newproj.create': 'Crea',
|
||||||
|
'newproj.locationLabel': 'Salva in',
|
||||||
|
'newproj.locationDefault': 'Progetti Open Design',
|
||||||
|
'newproj.locationExternalBase': 'Base esterna',
|
||||||
'newproj.createFromTemplate': 'Crea dal modello',
|
'newproj.createFromTemplate': 'Crea dal modello',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'Salva prima un progetto come modello (menu Condividi in un progetto).',
|
'Salva prima un progetto come modello (menu Condividi in un progetto).',
|
||||||
|
|
@ -1432,6 +1435,20 @@ export const it: Dict = {
|
||||||
'settings.designSystemsCategory': 'Categoria',
|
'settings.designSystemsCategory': 'Categoria',
|
||||||
'settings.designSystemsAllCategories': 'Tutte le categorie',
|
'settings.designSystemsAllCategories': 'Tutte le categorie',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Mostra nella galleria iniziale',
|
'settings.designSystemsShowInHomeGallery': 'Mostra nella galleria iniziale',
|
||||||
|
'settings.projectLocations': 'Posizioni dei progetti',
|
||||||
|
'settings.projectLocationsHint': 'Radici di archiviazione workspace',
|
||||||
|
'settings.projectLocationsDescription': 'Aggiungi basi di lavoro che possono contenere più cartelle di progetti Open Design. I nuovi progetti vengono salvati come una cartella nella base selezionata.',
|
||||||
|
'settings.projectLocationsSaveError': 'Impossibile salvare le posizioni dei progetti. Verifica che ogni percorso sia una cartella accessibile.',
|
||||||
|
'settings.projectLocationsSaved': 'Posizioni dei progetti salvate.',
|
||||||
|
'settings.projectLocationsScanError': 'Impossibile scansionare le posizioni dei progetti.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Scansione completata: {imported} importati, {existing} già registrati.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Nessuna cartella selezionata.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Questa base di lavoro è già stata aggiunta.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Base di lavoro · i progetti vengono creati qui come sottocartelle',
|
||||||
|
'settings.projectLocationsAddFolder': 'Aggiungi cartella…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Posizione predefinita',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Imposta come predefinita',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Posizione progetto predefinita aggiornata.',
|
||||||
'settings.librarySkills': 'Competenze',
|
'settings.librarySkills': 'Competenze',
|
||||||
'settings.libraryDesignSystems': 'Sistemi di design',
|
'settings.libraryDesignSystems': 'Sistemi di design',
|
||||||
'settings.librarySearch': 'Cerca...',
|
'settings.librarySearch': 'Cerca...',
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,9 @@ export const ja: Dict = {
|
||||||
'newproj.fileSingular': 'ファイル',
|
'newproj.fileSingular': 'ファイル',
|
||||||
'newproj.filePlural': 'ファイル',
|
'newproj.filePlural': 'ファイル',
|
||||||
'newproj.create': '作成',
|
'newproj.create': '作成',
|
||||||
|
'newproj.locationLabel': '保存先',
|
||||||
|
'newproj.locationDefault': 'Open Design プロジェクト',
|
||||||
|
'newproj.locationExternalBase': '外部ベース',
|
||||||
'newproj.createFromTemplate': 'テンプレートから作成',
|
'newproj.createFromTemplate': 'テンプレートから作成',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'最初にプロジェクトをテンプレートとして保存してください(プロジェクト内の共有メニュー)。',
|
'最初にプロジェクトをテンプレートとして保存してください(プロジェクト内の共有メニュー)。',
|
||||||
|
|
@ -1489,6 +1492,20 @@ export const ja: Dict = {
|
||||||
'settings.designSystemsCategory': 'カテゴリー',
|
'settings.designSystemsCategory': 'カテゴリー',
|
||||||
'settings.designSystemsAllCategories': 'すべてのカテゴリー',
|
'settings.designSystemsAllCategories': 'すべてのカテゴリー',
|
||||||
'settings.designSystemsShowInHomeGallery': 'ホームギャラリーに表示',
|
'settings.designSystemsShowInHomeGallery': 'ホームギャラリーに表示',
|
||||||
|
'settings.projectLocations': 'プロジェクトの場所',
|
||||||
|
'settings.projectLocationsHint': 'ワークスペース保存ルート',
|
||||||
|
'settings.projectLocationsDescription': '複数の Open Design プロジェクトフォルダを含められる作業ベースを追加します。新しいプロジェクトは選択したベース内の 1 つのフォルダとして保存されます。',
|
||||||
|
'settings.projectLocationsSaveError': 'プロジェクトの場所を保存できませんでした。各パスがアクセス可能なフォルダであることを確認してください。',
|
||||||
|
'settings.projectLocationsSaved': 'プロジェクトの場所を保存しました。',
|
||||||
|
'settings.projectLocationsScanError': 'プロジェクトの場所をスキャンできませんでした。',
|
||||||
|
'settings.projectLocationsScanComplete': 'スキャン完了: {imported} 件をインポート、{existing} 件は登録済みです。',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'フォルダが選択されていません。',
|
||||||
|
'settings.projectLocationsDuplicate': 'その作業ベースはすでに追加されています。',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': '作業ベース · プロジェクトはここにサブフォルダとして作成されます',
|
||||||
|
'settings.projectLocationsAddFolder': 'フォルダを追加…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'デフォルトの場所',
|
||||||
|
'settings.projectLocationsMakeDefault': 'デフォルトにする',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'デフォルトのプロジェクト場所を更新しました。',
|
||||||
'settings.librarySkills': 'スキル',
|
'settings.librarySkills': 'スキル',
|
||||||
'settings.libraryDesignSystems': 'デザインシステム',
|
'settings.libraryDesignSystems': 'デザインシステム',
|
||||||
'settings.librarySearch': '検索...',
|
'settings.librarySearch': '検索...',
|
||||||
|
|
|
||||||
|
|
@ -566,6 +566,9 @@ export const ko: Dict = {
|
||||||
'newproj.fileSingular': '파일',
|
'newproj.fileSingular': '파일',
|
||||||
'newproj.filePlural': '파일들',
|
'newproj.filePlural': '파일들',
|
||||||
'newproj.create': '생성',
|
'newproj.create': '생성',
|
||||||
|
'newproj.locationLabel': '저장 위치',
|
||||||
|
'newproj.locationDefault': 'Open Design 프로젝트',
|
||||||
|
'newproj.locationExternalBase': '외부 베이스',
|
||||||
'newproj.createFromTemplate': '템플릿으로 생성',
|
'newproj.createFromTemplate': '템플릿으로 생성',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'먼저 프로젝트를 템플릿으로 저장하세요 (프로젝트 내 공유 메뉴 이용).',
|
'먼저 프로젝트를 템플릿으로 저장하세요 (프로젝트 내 공유 메뉴 이용).',
|
||||||
|
|
@ -1602,6 +1605,20 @@ export const ko: Dict = {
|
||||||
'settings.designSystemsCategory': '카테고리',
|
'settings.designSystemsCategory': '카테고리',
|
||||||
'settings.designSystemsAllCategories': '모든 카테고리',
|
'settings.designSystemsAllCategories': '모든 카테고리',
|
||||||
'settings.designSystemsShowInHomeGallery': '홈 갤러리에 표시',
|
'settings.designSystemsShowInHomeGallery': '홈 갤러리에 표시',
|
||||||
|
'settings.projectLocations': '프로젝트 위치',
|
||||||
|
'settings.projectLocationsHint': '워크스페이스 저장 루트',
|
||||||
|
'settings.projectLocationsDescription': '여러 Open Design 프로젝트 폴더를 포함할 수 있는 작업 베이스를 추가합니다. 새 프로젝트는 선택한 베이스 안의 폴더로 저장됩니다.',
|
||||||
|
'settings.projectLocationsSaveError': '프로젝트 위치를 저장할 수 없습니다. 각 경로가 접근 가능한 폴더인지 확인하세요.',
|
||||||
|
'settings.projectLocationsSaved': '프로젝트 위치가 저장되었습니다.',
|
||||||
|
'settings.projectLocationsScanError': '프로젝트 위치를 스캔할 수 없습니다.',
|
||||||
|
'settings.projectLocationsScanComplete': '스캔 완료: {imported}개 가져옴, {existing}개는 이미 등록됨.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': '선택한 폴더가 없습니다.',
|
||||||
|
'settings.projectLocationsDuplicate': '해당 작업 베이스는 이미 추가되었습니다.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': '작업 베이스 · 프로젝트는 여기에 하위 폴더로 생성됩니다',
|
||||||
|
'settings.projectLocationsAddFolder': '폴더 추가…',
|
||||||
|
'settings.projectLocationsDefaultBadge': '기본 위치',
|
||||||
|
'settings.projectLocationsMakeDefault': '기본값으로 설정',
|
||||||
|
'settings.projectLocationsDefaultSaved': '기본 프로젝트 위치가 업데이트되었습니다.',
|
||||||
'settings.librarySkills': '스킬',
|
'settings.librarySkills': '스킬',
|
||||||
'settings.libraryDesignSystems': '디자인 시스템',
|
'settings.libraryDesignSystems': '디자인 시스템',
|
||||||
'settings.librarySearch': '검색...',
|
'settings.librarySearch': '검색...',
|
||||||
|
|
|
||||||
|
|
@ -566,6 +566,9 @@ export const pl: Dict = {
|
||||||
'newproj.fileSingular': 'plik',
|
'newproj.fileSingular': 'plik',
|
||||||
'newproj.filePlural': 'pliki',
|
'newproj.filePlural': 'pliki',
|
||||||
'newproj.create': 'Utwórz',
|
'newproj.create': 'Utwórz',
|
||||||
|
'newproj.locationLabel': 'Zapisz w',
|
||||||
|
'newproj.locationDefault': 'Projekty Open Design',
|
||||||
|
'newproj.locationExternalBase': 'Zewnętrzna baza',
|
||||||
'newproj.createFromTemplate': 'Utwórz z szablonu',
|
'newproj.createFromTemplate': 'Utwórz z szablonu',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'Najpierw zapisz projekt jako szablon (menu Udostępnij wewnątrz projektu).',
|
'Najpierw zapisz projekt jako szablon (menu Udostępnij wewnątrz projektu).',
|
||||||
|
|
@ -1552,6 +1555,20 @@ export const pl: Dict = {
|
||||||
'settings.designSystemsCategory': 'Kategoria',
|
'settings.designSystemsCategory': 'Kategoria',
|
||||||
'settings.designSystemsAllCategories': 'Wszystkie kategorie',
|
'settings.designSystemsAllCategories': 'Wszystkie kategorie',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Pokaż w galerii głównej',
|
'settings.designSystemsShowInHomeGallery': 'Pokaż w galerii głównej',
|
||||||
|
'settings.projectLocations': 'Lokalizacje projektów',
|
||||||
|
'settings.projectLocationsHint': 'Katalogi główne workspace',
|
||||||
|
'settings.projectLocationsDescription': 'Dodaj bazy robocze, które mogą zawierać wiele folderów projektów Open Design. Nowe projekty są zapisywane jako folder w wybranej bazie.',
|
||||||
|
'settings.projectLocationsSaveError': 'Nie udało się zapisać lokalizacji projektów. Sprawdź, czy każda ścieżka jest dostępnym folderem.',
|
||||||
|
'settings.projectLocationsSaved': 'Lokalizacje projektów zapisane.',
|
||||||
|
'settings.projectLocationsScanError': 'Nie udało się przeskanować lokalizacji projektów.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Skanowanie zakończone: zaimportowano {imported}, już zarejestrowano {existing}.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Nie wybrano folderu.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Ta baza robocza jest już dodana.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Baza robocza · projekty są tworzone tutaj jako podfoldery',
|
||||||
|
'settings.projectLocationsAddFolder': 'Dodaj folder…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Lokalizacja domyślna',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Ustaw jako domyślną',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Domyślna lokalizacja projektu zaktualizowana.',
|
||||||
'settings.librarySkills': 'Umiejętności',
|
'settings.librarySkills': 'Umiejętności',
|
||||||
'settings.libraryDesignSystems': 'Systemy projektowe',
|
'settings.libraryDesignSystems': 'Systemy projektowe',
|
||||||
'settings.librarySearch': 'Szukaj...',
|
'settings.librarySearch': 'Szukaj...',
|
||||||
|
|
|
||||||
|
|
@ -576,6 +576,9 @@ export const ptBR: Dict = {
|
||||||
'newproj.fileSingular': 'arquivo',
|
'newproj.fileSingular': 'arquivo',
|
||||||
'newproj.filePlural': 'arquivos',
|
'newproj.filePlural': 'arquivos',
|
||||||
'newproj.create': 'Criar',
|
'newproj.create': 'Criar',
|
||||||
|
'newproj.locationLabel': 'Salvar em',
|
||||||
|
'newproj.locationDefault': 'Projetos Open Design',
|
||||||
|
'newproj.locationExternalBase': 'Base externa',
|
||||||
'newproj.createLiveArtifact': 'Criar artefato live',
|
'newproj.createLiveArtifact': 'Criar artefato live',
|
||||||
'newproj.createFromTemplate': 'Criar a partir do template',
|
'newproj.createFromTemplate': 'Criar a partir do template',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
|
|
@ -1593,6 +1596,20 @@ export const ptBR: Dict = {
|
||||||
'settings.designSystemsCategory': 'Categoria',
|
'settings.designSystemsCategory': 'Categoria',
|
||||||
'settings.designSystemsAllCategories': 'Todas as categorias',
|
'settings.designSystemsAllCategories': 'Todas as categorias',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Mostrar na galeria inicial',
|
'settings.designSystemsShowInHomeGallery': 'Mostrar na galeria inicial',
|
||||||
|
'settings.projectLocations': 'Locais de projetos',
|
||||||
|
'settings.projectLocationsHint': 'Raízes de armazenamento do workspace',
|
||||||
|
'settings.projectLocationsDescription': 'Adicione bases de trabalho que podem conter várias pastas de projetos do Open Design. Novos projetos são salvos como uma pasta dentro da base selecionada.',
|
||||||
|
'settings.projectLocationsSaveError': 'Não foi possível salvar os locais de projetos. Verifique se cada caminho é uma pasta acessível.',
|
||||||
|
'settings.projectLocationsSaved': 'Locais de projetos salvos.',
|
||||||
|
'settings.projectLocationsScanError': 'Não foi possível escanear os locais de projetos.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Escaneamento concluído: {imported} importados, {existing} já registrados.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Nenhuma pasta selecionada.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Essa base de trabalho já foi adicionada.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Base de trabalho · projetos são criados aqui como subpastas',
|
||||||
|
'settings.projectLocationsAddFolder': 'Adicionar pasta…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Local padrão',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Tornar padrão',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Local padrão do projeto atualizado.',
|
||||||
'settings.librarySkills': 'Habilidades',
|
'settings.librarySkills': 'Habilidades',
|
||||||
'settings.libraryDesignSystems': 'Sistemas de design',
|
'settings.libraryDesignSystems': 'Sistemas de design',
|
||||||
'settings.librarySearch': 'Pesquisar...',
|
'settings.librarySearch': 'Pesquisar...',
|
||||||
|
|
|
||||||
|
|
@ -576,6 +576,9 @@ export const ru: Dict = {
|
||||||
'newproj.fileSingular': 'файл',
|
'newproj.fileSingular': 'файл',
|
||||||
'newproj.filePlural': 'файлов',
|
'newproj.filePlural': 'файлов',
|
||||||
'newproj.create': 'Создать',
|
'newproj.create': 'Создать',
|
||||||
|
'newproj.locationLabel': 'Сохранить в',
|
||||||
|
'newproj.locationDefault': 'Проекты Open Design',
|
||||||
|
'newproj.locationExternalBase': 'Внешняя база',
|
||||||
'newproj.createLiveArtifact': 'Создать live-артефакт',
|
'newproj.createLiveArtifact': 'Создать live-артефакт',
|
||||||
'newproj.createFromTemplate': 'Создать из шаблона',
|
'newproj.createFromTemplate': 'Создать из шаблона',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
|
|
@ -1593,6 +1596,20 @@ export const ru: Dict = {
|
||||||
'settings.designSystemsCategory': 'Категория',
|
'settings.designSystemsCategory': 'Категория',
|
||||||
'settings.designSystemsAllCategories': 'Все категории',
|
'settings.designSystemsAllCategories': 'Все категории',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Показывать в домашней галерее',
|
'settings.designSystemsShowInHomeGallery': 'Показывать в домашней галерее',
|
||||||
|
'settings.projectLocations': 'Расположения проектов',
|
||||||
|
'settings.projectLocationsHint': 'Корни хранения рабочих пространств',
|
||||||
|
'settings.projectLocationsDescription': 'Добавьте рабочие базы, которые могут содержать несколько папок проектов Open Design. Новые проекты сохраняются как папка внутри выбранной базы.',
|
||||||
|
'settings.projectLocationsSaveError': 'Не удалось сохранить расположения проектов. Проверьте, что каждый путь является доступной папкой.',
|
||||||
|
'settings.projectLocationsSaved': 'Расположения проектов сохранены.',
|
||||||
|
'settings.projectLocationsScanError': 'Не удалось просканировать расположения проектов.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Сканирование завершено: импортировано {imported}, уже зарегистрировано {existing}.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Папка не выбрана.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Эта рабочая база уже добавлена.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Рабочая база · проекты создаются здесь как подпапки',
|
||||||
|
'settings.projectLocationsAddFolder': 'Добавить папку…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Расположение по умолчанию',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Сделать по умолчанию',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Расположение проекта по умолчанию обновлено.',
|
||||||
'settings.librarySkills': 'Навыки',
|
'settings.librarySkills': 'Навыки',
|
||||||
'settings.libraryDesignSystems': 'Системы дизайна',
|
'settings.libraryDesignSystems': 'Системы дизайна',
|
||||||
'settings.librarySearch': 'Поиск...',
|
'settings.librarySearch': 'Поиск...',
|
||||||
|
|
|
||||||
|
|
@ -535,6 +535,9 @@ export const th: Dict = {
|
||||||
'newproj.fileSingular': 'ไฟล์',
|
'newproj.fileSingular': 'ไฟล์',
|
||||||
'newproj.filePlural': 'ไฟล์',
|
'newproj.filePlural': 'ไฟล์',
|
||||||
'newproj.create': 'สร้าง',
|
'newproj.create': 'สร้าง',
|
||||||
|
'newproj.locationLabel': 'บันทึกไปยัง',
|
||||||
|
'newproj.locationDefault': 'โปรเจกต์ Open Design',
|
||||||
|
'newproj.locationExternalBase': 'ฐานภายนอก',
|
||||||
'newproj.createLiveArtifact': 'สร้าง live artifact',
|
'newproj.createLiveArtifact': 'สร้าง live artifact',
|
||||||
'newproj.createFromTemplate': 'สร้างจากเทมเพลต',
|
'newproj.createFromTemplate': 'สร้างจากเทมเพลต',
|
||||||
'newproj.createDisabledTitle': 'คุณต้องบันทึกโปรเจกต์เป็นเทมเพลตก่อน',
|
'newproj.createDisabledTitle': 'คุณต้องบันทึกโปรเจกต์เป็นเทมเพลตก่อน',
|
||||||
|
|
@ -1469,6 +1472,20 @@ export const th: Dict = {
|
||||||
'settings.notifySoundBuzz': 'เป็นจังหวะกระตุ้นอารมณ์สั่นเลย',
|
'settings.notifySoundBuzz': 'เป็นจังหวะกระตุ้นอารมณ์สั่นเลย',
|
||||||
'settings.notifySoundTwoToneDown': 'โทนดังลดถอย 2 จังหวะ',
|
'settings.notifySoundTwoToneDown': 'โทนดังลดถอย 2 จังหวะ',
|
||||||
'settings.notifySoundThud': 'เสียงหนักเน้นโครมให้ระวัง',
|
'settings.notifySoundThud': 'เสียงหนักเน้นโครมให้ระวัง',
|
||||||
|
'settings.projectLocations': 'ตำแหน่งโปรเจกต์',
|
||||||
|
'settings.projectLocationsHint': 'รากที่เก็บเวิร์กสเปซ',
|
||||||
|
'settings.projectLocationsDescription': 'เพิ่มฐานงานที่สามารถเก็บโฟลเดอร์โปรเจกต์ Open Design ได้หลายรายการ โปรเจกต์ใหม่จะถูกบันทึกเป็นหนึ่งโฟลเดอร์ภายในฐานที่เลือก',
|
||||||
|
'settings.projectLocationsSaveError': 'ไม่สามารถบันทึกตำแหน่งโปรเจกต์ได้ ตรวจสอบว่าแต่ละพาธเป็นโฟลเดอร์ที่เข้าถึงได้',
|
||||||
|
'settings.projectLocationsSaved': 'บันทึกตำแหน่งโปรเจกต์แล้ว',
|
||||||
|
'settings.projectLocationsScanError': 'ไม่สามารถสแกนตำแหน่งโปรเจกต์ได้',
|
||||||
|
'settings.projectLocationsScanComplete': 'สแกนเสร็จแล้ว: นำเข้า {imported} รายการ, ลงทะเบียนไว้แล้ว {existing} รายการ',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'ไม่ได้เลือกโฟลเดอร์',
|
||||||
|
'settings.projectLocationsDuplicate': 'เพิ่มฐานงานนี้ไว้แล้ว',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'ฐานงาน · โปรเจกต์จะถูกสร้างเป็นโฟลเดอร์ย่อยที่นี่',
|
||||||
|
'settings.projectLocationsAddFolder': 'เพิ่มโฟลเดอร์…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'ตำแหน่งเริ่มต้น',
|
||||||
|
'settings.projectLocationsMakeDefault': 'ตั้งเป็นค่าเริ่มต้น',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'อัปเดตตำแหน่งโปรเจกต์เริ่มต้นแล้ว',
|
||||||
'settings.librarySkills': 'พวก Skills',
|
'settings.librarySkills': 'พวก Skills',
|
||||||
'settings.libraryDesignSystems': 'ตัวของระบบแบบ Design Systems',
|
'settings.libraryDesignSystems': 'ตัวของระบบแบบ Design Systems',
|
||||||
'settings.librarySearch': 'ต้องการหาสิ่งใด…',
|
'settings.librarySearch': 'ต้องการหาสิ่งใด…',
|
||||||
|
|
|
||||||
|
|
@ -556,6 +556,9 @@ export const tr: Dict = {
|
||||||
'newproj.fileSingular': 'dosya',
|
'newproj.fileSingular': 'dosya',
|
||||||
'newproj.filePlural': 'dosyalar',
|
'newproj.filePlural': 'dosyalar',
|
||||||
'newproj.create': 'Oluştur',
|
'newproj.create': 'Oluştur',
|
||||||
|
'newproj.locationLabel': 'Şuraya kaydet',
|
||||||
|
'newproj.locationDefault': 'Open Design projeleri',
|
||||||
|
'newproj.locationExternalBase': 'Harici taban',
|
||||||
'newproj.createFromTemplate': 'Şablondan oluştur',
|
'newproj.createFromTemplate': 'Şablondan oluştur',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
'Önce bir projeyi şablon olarak kaydedin (herhangi bir projenin içinde Paylaş menüsünden).',
|
'Önce bir projeyi şablon olarak kaydedin (herhangi bir projenin içinde Paylaş menüsünden).',
|
||||||
|
|
@ -1539,6 +1542,20 @@ export const tr: Dict = {
|
||||||
'settings.designSystemsCategory': 'Kategori',
|
'settings.designSystemsCategory': 'Kategori',
|
||||||
'settings.designSystemsAllCategories': 'Tüm kategoriler',
|
'settings.designSystemsAllCategories': 'Tüm kategoriler',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Ana galeride göster',
|
'settings.designSystemsShowInHomeGallery': 'Ana galeride göster',
|
||||||
|
'settings.projectLocations': 'Proje konumları',
|
||||||
|
'settings.projectLocationsHint': 'Çalışma alanı depolama kökleri',
|
||||||
|
'settings.projectLocationsDescription': 'Birden fazla Open Design proje klasörü içerebilen çalışma tabanları ekleyin. Yeni projeler seçilen tabanın içinde bir klasör olarak kaydedilir.',
|
||||||
|
'settings.projectLocationsSaveError': 'Proje konumları kaydedilemedi. Her yolun erişilebilir bir klasör olduğunu kontrol edin.',
|
||||||
|
'settings.projectLocationsSaved': 'Proje konumları kaydedildi.',
|
||||||
|
'settings.projectLocationsScanError': 'Proje konumları taranamadı.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Tarama tamamlandı: {imported} içe aktarıldı, {existing} zaten kayıtlı.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Klasör seçilmedi.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Bu çalışma tabanı zaten eklendi.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Çalışma tabanı · projeler burada alt klasörler olarak oluşturulur',
|
||||||
|
'settings.projectLocationsAddFolder': 'Klasör ekle…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Varsayılan konum',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Varsayılan yap',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Varsayılan proje konumu güncellendi.',
|
||||||
'settings.librarySkills': 'Beceriler',
|
'settings.librarySkills': 'Beceriler',
|
||||||
'settings.libraryDesignSystems': 'Tasarım sistemleri',
|
'settings.libraryDesignSystems': 'Tasarım sistemleri',
|
||||||
'settings.librarySearch': 'Ara...',
|
'settings.librarySearch': 'Ara...',
|
||||||
|
|
|
||||||
|
|
@ -578,6 +578,9 @@ export const uk: Dict = {
|
||||||
'newproj.fileSingular': 'файл',
|
'newproj.fileSingular': 'файл',
|
||||||
'newproj.filePlural': 'файли',
|
'newproj.filePlural': 'файли',
|
||||||
'newproj.create': 'Створити',
|
'newproj.create': 'Створити',
|
||||||
|
'newproj.locationLabel': 'Зберегти в',
|
||||||
|
'newproj.locationDefault': 'Проєкти Open Design',
|
||||||
|
'newproj.locationExternalBase': 'Зовнішня база',
|
||||||
'newproj.createLiveArtifact': 'Створити live-артефакт',
|
'newproj.createLiveArtifact': 'Створити live-артефакт',
|
||||||
'newproj.createFromTemplate': 'Створити з шаблону',
|
'newproj.createFromTemplate': 'Створити з шаблону',
|
||||||
'newproj.createDisabledTitle':
|
'newproj.createDisabledTitle':
|
||||||
|
|
@ -1594,6 +1597,20 @@ export const uk: Dict = {
|
||||||
'settings.designSystemsCategory': 'Категорія',
|
'settings.designSystemsCategory': 'Категорія',
|
||||||
'settings.designSystemsAllCategories': 'Усі категорії',
|
'settings.designSystemsAllCategories': 'Усі категорії',
|
||||||
'settings.designSystemsShowInHomeGallery': 'Показувати в домашній галереї',
|
'settings.designSystemsShowInHomeGallery': 'Показувати в домашній галереї',
|
||||||
|
'settings.projectLocations': 'Розташування проєктів',
|
||||||
|
'settings.projectLocationsHint': 'Корені зберігання робочих просторів',
|
||||||
|
'settings.projectLocationsDescription': 'Додайте робочі бази, які можуть містити кілька тек проєктів Open Design. Нові проєкти зберігаються як тека всередині вибраної бази.',
|
||||||
|
'settings.projectLocationsSaveError': 'Не вдалося зберегти розташування проєктів. Перевірте, що кожен шлях є доступною текою.',
|
||||||
|
'settings.projectLocationsSaved': 'Розташування проєктів збережено.',
|
||||||
|
'settings.projectLocationsScanError': 'Не вдалося просканувати розташування проєктів.',
|
||||||
|
'settings.projectLocationsScanComplete': 'Сканування завершено: імпортовано {imported}, уже зареєстровано {existing}.',
|
||||||
|
'settings.projectLocationsNoFolderSelected': 'Теку не вибрано.',
|
||||||
|
'settings.projectLocationsDuplicate': 'Цю робочу базу вже додано.',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': 'Робоча база · проєкти створюються тут як підтеки',
|
||||||
|
'settings.projectLocationsAddFolder': 'Додати теку…',
|
||||||
|
'settings.projectLocationsDefaultBadge': 'Типове розташування',
|
||||||
|
'settings.projectLocationsMakeDefault': 'Зробити типовим',
|
||||||
|
'settings.projectLocationsDefaultSaved': 'Типове розташування проєкту оновлено.',
|
||||||
'settings.librarySkills': 'Навички',
|
'settings.librarySkills': 'Навички',
|
||||||
'settings.libraryDesignSystems': 'Системи дизайну',
|
'settings.libraryDesignSystems': 'Системи дизайну',
|
||||||
'settings.librarySearch': 'Пошук...',
|
'settings.librarySearch': 'Пошук...',
|
||||||
|
|
|
||||||
|
|
@ -1152,6 +1152,9 @@ export const zhCN: Dict = {
|
||||||
'newproj.fileSingular': '个文件',
|
'newproj.fileSingular': '个文件',
|
||||||
'newproj.filePlural': '个文件',
|
'newproj.filePlural': '个文件',
|
||||||
'newproj.create': '创建',
|
'newproj.create': '创建',
|
||||||
|
'newproj.locationLabel': '保存到',
|
||||||
|
'newproj.locationDefault': 'Open Design 项目',
|
||||||
|
'newproj.locationExternalBase': '外部基目录',
|
||||||
'newproj.createLiveArtifact': '创建实时制品',
|
'newproj.createLiveArtifact': '创建实时制品',
|
||||||
'newproj.createFromTemplate': '基于模板创建',
|
'newproj.createFromTemplate': '基于模板创建',
|
||||||
'newproj.createDisabledTitle': '请先在任意项目内通过「分享」菜单将其保存为模板。',
|
'newproj.createDisabledTitle': '请先在任意项目内通过「分享」菜单将其保存为模板。',
|
||||||
|
|
@ -2299,6 +2302,20 @@ export const zhCN: Dict = {
|
||||||
'settings.designSystemsCategory': '分类',
|
'settings.designSystemsCategory': '分类',
|
||||||
'settings.designSystemsAllCategories': '所有分类',
|
'settings.designSystemsAllCategories': '所有分类',
|
||||||
'settings.designSystemsShowInHomeGallery': '在首页 Gallery 中显示',
|
'settings.designSystemsShowInHomeGallery': '在首页 Gallery 中显示',
|
||||||
|
'settings.projectLocations': '项目位置',
|
||||||
|
'settings.projectLocationsHint': '工作区存储根目录',
|
||||||
|
'settings.projectLocationsDescription': '添加可包含多个 Open Design 项目文件夹的工作基目录。新项目会作为所选基目录内的一个文件夹保存。',
|
||||||
|
'settings.projectLocationsSaveError': '无法保存项目位置。请检查每个路径都是可访问的文件夹。',
|
||||||
|
'settings.projectLocationsSaved': '项目位置已保存。',
|
||||||
|
'settings.projectLocationsScanError': '无法扫描项目位置。',
|
||||||
|
'settings.projectLocationsScanComplete': '扫描完成:已导入 {imported} 个,已有 {existing} 个。',
|
||||||
|
'settings.projectLocationsNoFolderSelected': '未选择文件夹。',
|
||||||
|
'settings.projectLocationsDuplicate': '这个工作基目录已添加。',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': '工作基目录 · 项目会作为子文件夹创建在这里',
|
||||||
|
'settings.projectLocationsAddFolder': '添加文件夹…',
|
||||||
|
'settings.projectLocationsDefaultBadge': '默认位置',
|
||||||
|
'settings.projectLocationsMakeDefault': '设为默认',
|
||||||
|
'settings.projectLocationsDefaultSaved': '默认项目位置已更新。',
|
||||||
'settings.librarySkills': '技能',
|
'settings.librarySkills': '技能',
|
||||||
'settings.libraryDesignSystems': '设计系统',
|
'settings.libraryDesignSystems': '设计系统',
|
||||||
'settings.librarySearch': '搜索...',
|
'settings.librarySearch': '搜索...',
|
||||||
|
|
|
||||||
|
|
@ -754,6 +754,9 @@ export const zhTW: Dict = {
|
||||||
'newproj.fileSingular': '個檔案',
|
'newproj.fileSingular': '個檔案',
|
||||||
'newproj.filePlural': '個檔案',
|
'newproj.filePlural': '個檔案',
|
||||||
'newproj.create': '建立',
|
'newproj.create': '建立',
|
||||||
|
'newproj.locationLabel': '儲存到',
|
||||||
|
'newproj.locationDefault': 'Open Design 專案',
|
||||||
|
'newproj.locationExternalBase': '外部基目錄',
|
||||||
'newproj.createLiveArtifact': '建立即時成品',
|
'newproj.createLiveArtifact': '建立即時成品',
|
||||||
'newproj.createFromTemplate': '基於範本建立',
|
'newproj.createFromTemplate': '基於範本建立',
|
||||||
'newproj.createDisabledTitle': '請先在任意專案內透過「分享」選單將其儲存為範本。',
|
'newproj.createDisabledTitle': '請先在任意專案內透過「分享」選單將其儲存為範本。',
|
||||||
|
|
@ -1851,6 +1854,20 @@ export const zhTW: Dict = {
|
||||||
'settings.designSystemsCategory': '分類',
|
'settings.designSystemsCategory': '分類',
|
||||||
'settings.designSystemsAllCategories': '所有分類',
|
'settings.designSystemsAllCategories': '所有分類',
|
||||||
'settings.designSystemsShowInHomeGallery': '在首頁 Gallery 中顯示',
|
'settings.designSystemsShowInHomeGallery': '在首頁 Gallery 中顯示',
|
||||||
|
'settings.projectLocations': '專案位置',
|
||||||
|
'settings.projectLocationsHint': '工作區儲存根目錄',
|
||||||
|
'settings.projectLocationsDescription': '新增可包含多個 Open Design 專案資料夾的工作基目錄。新專案會儲存為所選基目錄中的一個資料夾。',
|
||||||
|
'settings.projectLocationsSaveError': '無法儲存專案位置。請確認每個路徑都是可存取的資料夾。',
|
||||||
|
'settings.projectLocationsSaved': '專案位置已儲存。',
|
||||||
|
'settings.projectLocationsScanError': '無法掃描專案位置。',
|
||||||
|
'settings.projectLocationsScanComplete': '掃描完成:已匯入 {imported} 個,已有 {existing} 個。',
|
||||||
|
'settings.projectLocationsNoFolderSelected': '未選取資料夾。',
|
||||||
|
'settings.projectLocationsDuplicate': '這個工作基目錄已新增。',
|
||||||
|
'settings.projectLocationsWorkBaseMeta': '工作基目錄 · 專案會在這裡建立為子資料夾',
|
||||||
|
'settings.projectLocationsAddFolder': '新增資料夾…',
|
||||||
|
'settings.projectLocationsDefaultBadge': '預設位置',
|
||||||
|
'settings.projectLocationsMakeDefault': '設為預設',
|
||||||
|
'settings.projectLocationsDefaultSaved': '預設專案位置已更新。',
|
||||||
'settings.librarySkills': '技能',
|
'settings.librarySkills': '技能',
|
||||||
'settings.libraryDesignSystems': '設計系統',
|
'settings.libraryDesignSystems': '設計系統',
|
||||||
'settings.librarySearch': '搜尋...',
|
'settings.librarySearch': '搜尋...',
|
||||||
|
|
|
||||||
|
|
@ -444,6 +444,20 @@ export interface Dict {
|
||||||
'settings.designSystemsCategory': string;
|
'settings.designSystemsCategory': string;
|
||||||
'settings.designSystemsAllCategories': string;
|
'settings.designSystemsAllCategories': string;
|
||||||
'settings.designSystemsShowInHomeGallery': string;
|
'settings.designSystemsShowInHomeGallery': string;
|
||||||
|
'settings.projectLocations': string;
|
||||||
|
'settings.projectLocationsHint': string;
|
||||||
|
'settings.projectLocationsDescription': string;
|
||||||
|
'settings.projectLocationsSaveError': string;
|
||||||
|
'settings.projectLocationsSaved': string;
|
||||||
|
'settings.projectLocationsScanError': string;
|
||||||
|
'settings.projectLocationsScanComplete': string;
|
||||||
|
'settings.projectLocationsNoFolderSelected': string;
|
||||||
|
'settings.projectLocationsDuplicate': string;
|
||||||
|
'settings.projectLocationsWorkBaseMeta': string;
|
||||||
|
'settings.projectLocationsAddFolder': string;
|
||||||
|
'settings.projectLocationsDefaultBadge': string;
|
||||||
|
'settings.projectLocationsMakeDefault': string;
|
||||||
|
'settings.projectLocationsDefaultSaved': string;
|
||||||
'settings.librarySkills': string;
|
'settings.librarySkills': string;
|
||||||
'settings.libraryDesignSystems': string;
|
'settings.libraryDesignSystems': string;
|
||||||
'settings.librarySearch': string;
|
'settings.librarySearch': string;
|
||||||
|
|
@ -1431,6 +1445,9 @@ export interface Dict {
|
||||||
'newproj.fileSingular': string;
|
'newproj.fileSingular': string;
|
||||||
'newproj.filePlural': string;
|
'newproj.filePlural': string;
|
||||||
'newproj.create': string;
|
'newproj.create': string;
|
||||||
|
'newproj.locationLabel': string;
|
||||||
|
'newproj.locationDefault': string;
|
||||||
|
'newproj.locationExternalBase': string;
|
||||||
'newproj.createLiveArtifact': string;
|
'newproj.createLiveArtifact': string;
|
||||||
'newproj.createFromTemplate': string;
|
'newproj.createFromTemplate': string;
|
||||||
'newproj.createDisabledTitle': string;
|
'newproj.createDisabledTitle': string;
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,8 @@ export const DEFAULT_CONFIG: AppConfig = {
|
||||||
pet: DEFAULT_PET,
|
pet: DEFAULT_PET,
|
||||||
notifications: DEFAULT_NOTIFICATIONS,
|
notifications: DEFAULT_NOTIFICATIONS,
|
||||||
orbit: DEFAULT_ORBIT,
|
orbit: DEFAULT_ORBIT,
|
||||||
|
projectLocations: [],
|
||||||
|
defaultProjectLocationId: 'default',
|
||||||
// Telemetry defaults to ON so fresh-install users emit onboarding /
|
// Telemetry defaults to ON so fresh-install users emit onboarding /
|
||||||
// ui_click events from the first frame. The disclosure modal still
|
// ui_click events from the first frame. The disclosure modal still
|
||||||
// appears after `onboardingCompleted` flips, and Settings → Privacy
|
// appears after `onboardingCompleted` flips, and Settings → Privacy
|
||||||
|
|
@ -688,6 +690,12 @@ export function mergeDaemonConfig(
|
||||||
if (daemonConfig.customInstructions !== undefined) {
|
if (daemonConfig.customInstructions !== undefined) {
|
||||||
next.customInstructions = daemonConfig.customInstructions ?? undefined;
|
next.customInstructions = daemonConfig.customInstructions ?? undefined;
|
||||||
}
|
}
|
||||||
|
if (daemonConfig.projectLocations !== undefined) {
|
||||||
|
next.projectLocations = daemonConfig.projectLocations;
|
||||||
|
}
|
||||||
|
if (daemonConfig.defaultProjectLocationId !== undefined) {
|
||||||
|
next.defaultProjectLocationId = daemonConfig.defaultProjectLocationId ?? 'default';
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -802,6 +810,8 @@ export async function syncConfigToDaemon(
|
||||||
telemetry: config.telemetry,
|
telemetry: config.telemetry,
|
||||||
privacyDecisionAt: config.privacyDecisionAt,
|
privacyDecisionAt: config.privacyDecisionAt,
|
||||||
customInstructions: config.customInstructions ?? null,
|
customInstructions: config.customInstructions ?? null,
|
||||||
|
projectLocations: config.projectLocations ?? [],
|
||||||
|
defaultProjectLocationId: config.defaultProjectLocationId ?? 'default',
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/app-config', {
|
const response = await fetch('/api/app-config', {
|
||||||
|
|
|
||||||
55
apps/web/src/state/project-locations.ts
Normal file
55
apps/web/src/state/project-locations.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import type {
|
||||||
|
ProjectLocation,
|
||||||
|
ProjectLocationsResponse,
|
||||||
|
ScanProjectLocationsResponse,
|
||||||
|
UpdateProjectLocationsRequest,
|
||||||
|
} from '@open-design/contracts';
|
||||||
|
|
||||||
|
export async function fetchProjectLocations(): Promise<ProjectLocation[]> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/project-locations');
|
||||||
|
if (!resp.ok) return [];
|
||||||
|
const json = (await resp.json()) as ProjectLocationsResponse;
|
||||||
|
return Array.isArray(json.locations) ? json.locations : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProjectLocations(
|
||||||
|
locations: UpdateProjectLocationsRequest['locations'],
|
||||||
|
): Promise<ProjectLocation[] | null> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/project-locations', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ locations }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const json = (await resp.json()) as ProjectLocationsResponse;
|
||||||
|
return Array.isArray(json.locations) ? json.locations : [];
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanProjectLocations(): Promise<ScanProjectLocationsResponse | null> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/project-locations/scan', { method: 'POST' });
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
return (await resp.json()) as ScanProjectLocationsResponse;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openProjectLocationFolderDialog(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/dialog/open-folder', { method: 'POST' });
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const json = (await resp.json()) as { path?: string | null };
|
||||||
|
return typeof json.path === 'string' && json.path.trim() ? json.path : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -53,6 +53,7 @@ export async function getProject(id: string): Promise<Project | null> {
|
||||||
|
|
||||||
export async function createProject(input: {
|
export async function createProject(input: {
|
||||||
name: string;
|
name: string;
|
||||||
|
projectLocationId?: string;
|
||||||
skillId: string | null;
|
skillId: string | null;
|
||||||
designSystemId: string | null;
|
designSystemId: string | null;
|
||||||
pendingPrompt?: string;
|
pendingPrompt?: string;
|
||||||
|
|
|
||||||
|
|
@ -1146,6 +1146,92 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section { display: flex; flex-direction: column; gap: 12px; }
|
.settings-section { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.project-locations-section { gap: 14px; }
|
||||||
|
.project-location-card,
|
||||||
|
.project-location-edit {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
transition: border-color 120ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 120ms cubic-bezier(0.23, 1, 0.32, 1), background 120ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
}
|
||||||
|
.project-location-card:hover,
|
||||||
|
.project-location-edit:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
.project-location-card.is-default,
|
||||||
|
.project-location-edit.is-default {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
.project-location-card code {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.project-location-default-control span {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
padding: 4px 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.project-location-default-control {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-self: end;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.project-location-default-control input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.project-location-default-control input:checked + span {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.project-location-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.project-location-edit {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.project-location-edit-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.project-location-edit-main code {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.project-location-edit-main small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.project-location-add { align-self: flex-start; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.project-location-edit { grid-template-columns: 1fr; align-items: stretch; }
|
||||||
|
.project-location-default-control { justify-self: start; }
|
||||||
|
}
|
||||||
.settings-section-connectors { gap: 16px; }
|
.settings-section-connectors { gap: 16px; }
|
||||||
/* Credentials sit above the catalog now; the divider lives under the field
|
/* Credentials sit above the catalog now; the divider lives under the field
|
||||||
so the eye reads "configure key → catalog unlocks below". */
|
so the eye reads "configure key → catalog unlocks below". */
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import type {
|
||||||
ProviderModelsRequest,
|
ProviderModelsRequest,
|
||||||
ProviderModelsResponse,
|
ProviderModelsResponse,
|
||||||
Project,
|
Project,
|
||||||
|
ProjectLocationPrefs,
|
||||||
ProjectPlatform,
|
ProjectPlatform,
|
||||||
PreviewCommentMember,
|
PreviewCommentMember,
|
||||||
PreviewAnnotationStyle,
|
PreviewAnnotationStyle,
|
||||||
|
|
@ -86,6 +87,7 @@ export type {
|
||||||
ChatCommentSelectionKind,
|
ChatCommentSelectionKind,
|
||||||
OrbitRunSummary,
|
OrbitRunSummary,
|
||||||
OrbitStatusResponse,
|
OrbitStatusResponse,
|
||||||
|
ProjectLocation,
|
||||||
PreviewCommentMember,
|
PreviewCommentMember,
|
||||||
PreviewAnnotationStyle,
|
PreviewAnnotationStyle,
|
||||||
PreviewCommentSelectionKind,
|
PreviewCommentSelectionKind,
|
||||||
|
|
@ -369,6 +371,8 @@ export interface AppConfig {
|
||||||
// PrivacySection persist it through `syncConfigToDaemon`.
|
// PrivacySection persist it through `syncConfigToDaemon`.
|
||||||
telemetry?: TelemetryConfig;
|
telemetry?: TelemetryConfig;
|
||||||
customInstructions?: string;
|
customInstructions?: string;
|
||||||
|
projectLocations?: ProjectLocationPrefs[];
|
||||||
|
defaultProjectLocationId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelemetryConfig {
|
export interface TelemetryConfig {
|
||||||
|
|
|
||||||
|
|
@ -1383,6 +1383,7 @@ export type TrackingSettingsArea =
|
||||||
| 'notifications'
|
| 'notifications'
|
||||||
| 'pets'
|
| 'pets'
|
||||||
| 'design_systems'
|
| 'design_systems'
|
||||||
|
| 'project_locations'
|
||||||
| 'privacy'
|
| 'privacy'
|
||||||
| 'about';
|
| 'about';
|
||||||
|
|
||||||
|
|
@ -2220,6 +2221,8 @@ export function settingsSectionToTracking(
|
||||||
return 'skills';
|
return 'skills';
|
||||||
case 'designSystems':
|
case 'designSystems':
|
||||||
return 'design_systems';
|
return 'design_systems';
|
||||||
|
case 'projectLocations':
|
||||||
|
return 'project_locations';
|
||||||
case 'memory':
|
case 'memory':
|
||||||
return 'memory';
|
return 'memory';
|
||||||
case 'privacy':
|
case 'privacy':
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@ export interface OrbitConfigPrefs {
|
||||||
templateSkillId?: string | null;
|
templateSkillId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectLocationPrefs {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppConfigPrefs {
|
export interface AppConfigPrefs {
|
||||||
onboardingCompleted?: boolean;
|
onboardingCompleted?: boolean;
|
||||||
agentId?: string | null;
|
agentId?: string | null;
|
||||||
|
|
@ -40,6 +46,10 @@ export interface AppConfigPrefs {
|
||||||
privacyDecisionAt?: number | null;
|
privacyDecisionAt?: number | null;
|
||||||
orbit?: OrbitConfigPrefs;
|
orbit?: OrbitConfigPrefs;
|
||||||
customInstructions?: string | null;
|
customInstructions?: string | null;
|
||||||
|
/** External project library roots. The daemon adds its built-in .od/projects location at read time. */
|
||||||
|
projectLocations?: ProjectLocationPrefs[];
|
||||||
|
/** Project location id used for new projects when the create request does not choose one explicitly. */
|
||||||
|
defaultProjectLocationId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfigResponse {
|
export interface AppConfigResponse {
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,9 @@ export interface ProjectMetadata {
|
||||||
// directly inside the user's folder. Stored as the realpath() result so
|
// directly inside the user's folder. Stored as the realpath() result so
|
||||||
// symlinks can't redirect writes after import time.
|
// symlinks can't redirect writes after import time.
|
||||||
baseDir?: string;
|
baseDir?: string;
|
||||||
|
// Project library location that owns baseDir when the project was created
|
||||||
|
// under a configured project location root.
|
||||||
|
projectLocationId?: string;
|
||||||
// PR #974: marker stamped by the daemon's HMAC-gated import handler
|
// PR #974: marker stamped by the daemon's HMAC-gated import handler
|
||||||
// when a folder import passed the desktop-main-process trust gate.
|
// when a folder import passed the desktop-main-process trust gate.
|
||||||
// Only set on folder-imported projects (`baseDir` set) and only when
|
// Only set on folder-imported projects (`baseDir` set) and only when
|
||||||
|
|
@ -206,6 +209,8 @@ export interface Conversation {
|
||||||
|
|
||||||
export interface CreateProjectRequest {
|
export interface CreateProjectRequest {
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Optional project library location id. Omit or use `default` for .od/projects. */
|
||||||
|
projectLocationId?: string;
|
||||||
skillId?: string | null;
|
skillId?: string | null;
|
||||||
designSystemId?: string | null;
|
designSystemId?: string | null;
|
||||||
pendingPrompt?: string;
|
pendingPrompt?: string;
|
||||||
|
|
@ -251,6 +256,39 @@ export interface CreateProjectResponse extends ProjectResponse {
|
||||||
appliedPluginSnapshotId?: string;
|
appliedPluginSnapshotId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectLocation {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
builtIn?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectLocationsResponse {
|
||||||
|
locations: ProjectLocation[];
|
||||||
|
removedProjectIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProjectLocationsRequest {
|
||||||
|
locations: Array<{ id?: string; name?: string; path: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectManifest {
|
||||||
|
schemaVersion: 1;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
skillId?: string | null;
|
||||||
|
designSystemId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanProjectLocationsResponse {
|
||||||
|
scanned: number;
|
||||||
|
imported: Project[];
|
||||||
|
existing: string[];
|
||||||
|
skipped: Array<{ path: string; reason: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/import/folder — create a project rooted at an existing local
|
// POST /api/import/folder — create a project rooted at an existing local
|
||||||
// folder. The submitted baseDir is stored as the project's metadata.baseDir
|
// folder. The submitted baseDir is stored as the project's metadata.baseDir
|
||||||
// (after realpath canonicalization) and OD reads/writes directly inside it.
|
// (after realpath canonicalization) and OD reads/writes directly inside it.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue