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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLA.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ Signed-off by: <name> <email> — GitHub @<handle> — YYYY-MM-DD
### Signatories

- Hako (devswha) — project owner; copyright holder, not a licensor to itself.
- Signed-off by: 이종명 <114718483+lee98www@users.noreply.github.com> — GitHub @lee98www — 2026-09-04

---

Expand Down
9 changes: 8 additions & 1 deletion server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import gitRoutes from './routes/git.js';
import authRoutes from './routes/auth.js';
import settingsRoutes from './routes/settings.js';
import { createGjcAppFactory } from './app-factory.js';
import { isWorkspaceRoot } from './modules/projects/index.js';
import projectModuleRoutes from './modules/projects/projects.routes.js';
import notificationRoutes from './modules/notifications/notifications.routes.js';
import userRoutes from './routes/user.js';
Expand Down Expand Up @@ -577,7 +578,13 @@ app.get('/api/projects/:projectId/files', authenticateToken, async (req, res) =>
return res.status(404).json({ error: `Project path not found: ${actualPath}` });
}

const files = await getFileTree(actualPath, 10, 0, true);
// A workspace root (~/Projects: no repo of its own, dozens of child
// repos) is where a session picks a child repo, not a tree to mention
// files from. Walking it ten levels deep stats every file in every
// repo and pins the event loop for minutes, so it lists the root's own
// entries but does not open any child repo.
const depth = (await isWorkspaceRoot(actualPath)) ? 0 : 10;
const files = await getFileTree(actualPath, depth, 0, true);
res.json(files);
} catch (error) {
console.error('[ERROR] File tree error:', error.message);
Expand Down
2 changes: 2 additions & 0 deletions server/modules/projects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import {
generateDisplayName as displayName,
getProjectsWithSessions as projectsWithSessions,
} from './services/projects-with-sessions-fetch.service.js';
import { isWorkspaceRoot as workspaceRoot } from './services/workspace-target.service.js';

export {
workspaceRoot as isWorkspaceRoot,
displayName as generateDisplayName,
grantAlwaysAllow as grantProjectAlwaysAllow,
projectsWithSessions as getProjectsWithSessions,
Expand Down
16 changes: 16 additions & 0 deletions server/modules/projects/projects.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import express from 'express';
import { deleteOrArchiveProject, restoreArchivedProject } from '@/modules/projects/services/project-delete.service.js';
import { startCloneProject, type CloneProjectOperation } from '@/modules/projects/services/project-clone.service.js';
import { createProject, promoteProjectOrigin, updateProjectDisplayName } from '@/modules/projects/services/project-management.service.js';
import { descendIntoChild, resolveWorkspaceTarget } from '@/modules/projects/services/workspace-target.service.js';
import {
getProjectPermissions,
listConfiguredProjectPermissions,
Expand Down Expand Up @@ -95,6 +96,21 @@ router.post('/:projectId/promote', asyncHandler(async (request, response) => {
response.json({ success: true, project: promoteProjectOrigin(projectId) });
}));

router.get('/:projectId/resolve-target', asyncHandler(async (request, response) => {
const projectId = routeProjectId(request.params.projectId, true);
const result = await resolveWorkspaceTarget(projectId, queryText(request.query.text));
response.json(createApiSuccessResponse(result));
}));

router.post('/:projectId/descend', asyncHandler(async (request, response) => {
const projectId = routeProjectId(request.params.projectId, true);
const body: { path?: unknown } = request.body ?? {};
const childPath = typeof body.path === 'string' ? body.path : '';
if (!childPath) throw new AppError('path is required', { code: 'NOT_WORKSPACE_CHILD', statusCode: 400 });
const { created, project } = await descendIntoChild(projectId, childPath);
response.status(created ? 201 : 200).json(createApiSuccessResponse(project));
}));

router.get('/:projectId/sessions', asyncHandler(async (request, response) => {
const sessions = await getProjectSessionsPage(routeProjectId(request.params.projectId), {
limit: nonNegativeQueryNumber(request.query.limit, 'limit', 20),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const projectStore: CreateProjectDependencies = {
validatePath: validateWorkspacePath, // shared workspace gate from utils
};

function projectApiView(project: ProjectRepositoryRow): ProjectApiView {
export function projectApiView(project: ProjectRepositoryRow): ProjectApiView {
const fullPath = project.project_path;
const view: ProjectApiView = {
projectId: project.project_id,
Expand Down
73 changes: 73 additions & 0 deletions server/modules/projects/services/workspace-target-scoring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
export type WorkspaceCandidateReason = 'mention' | 'partial' | 'recent';

export type WorkspaceCandidate = {
path: string;
name: string;
score: number;
reason: WorkspaceCandidateReason;
};

export type WorkspaceScoringChild = {
path: string;
name: string;
packageName: string | null;
mtimeMs: number;
};

const TOKEN_CHARACTER = /[a-z0-9._-]/;

function candidateNames(child: WorkspaceScoringChild): string[] {
const names = [child.name.toLowerCase(), child.packageName?.toLowerCase() ?? null].filter(
(name): name is string => Boolean(name),
);
return Array.from(new Set(names));
}

// `name` occurs in the text with nothing token-like on either side, so a repo named
// `gajae-code` is not "mentioned" by a message about `gajae-code-app`. This is how a
// name made of characters outside the token class (a Korean directory) gets found.
function mentionedWhole(name: string, lowerText: string): boolean {
let from = 0;
for (;;) {
const at = lowerText.indexOf(name, from);
if (at === -1) return false;
const before = at === 0 ? '' : lowerText[at - 1];
const after = lowerText[at + name.length] ?? '';
if (!TOKEN_CHARACTER.test(before) && !TOKEN_CHARACTER.test(after)) return true;
from = at + 1;
}
}

function scoreAgainstName(name: string, lowerText: string, tokens: string[]): { score: number; reason: WorkspaceCandidateReason } {
if (tokens.includes(name)) return { score: 100, reason: 'mention' };
// Names the ASCII tokenizer would mangle (Korean directories or symbols like
// c++) can only be found by whole mention; boundaries keep short ASCII names
// such as "go" from false-positiving.
if ((name.length >= 4 || (name.length >= 2 && /[^a-z0-9._-]/.test(name))) && mentionedWhole(name, lowerText)) return { score: 80, reason: 'mention' };
if (tokens.some((token) => token.length >= 3 && name.startsWith(token))) return { score: 40, reason: 'partial' };
return { score: 0, reason: 'recent' };
}

/**
* Ranks every child repository of a workspace against free-form task text. Each child
* scores by its best match across the directory name and the (scope-stripped)
* package.json name; ties, and all children when the text is empty, fall back to
* directory recency. The whole list comes back so a picker can show every repo, with
* the likely target first.
*/
export function scoreWorkspaceCandidates(text: string, children: WorkspaceScoringChild[]): WorkspaceCandidate[] {
const lowerText = text.trim().toLowerCase();
const tokens = lowerText.split(/[^a-z0-9._-]+/).filter(Boolean);

const scored = children.map((child) => {
let best = { score: 0, reason: 'recent' as WorkspaceCandidateReason };
for (const name of candidateNames(child)) {
const candidate = scoreAgainstName(name, lowerText, tokens);
if (candidate.score > best.score) best = candidate;
}
return { path: child.path, name: child.name, score: best.score, reason: best.reason, mtimeMs: child.mtimeMs };
});

scored.sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs);
return scored.map(({ path, name, score, reason }) => ({ path, name, score, reason }));
}
150 changes: 150 additions & 0 deletions server/modules/projects/services/workspace-target.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import fs from 'node:fs/promises';
import path from 'node:path';

import { projectsDb } from '@/modules/database/index.js';
import { projectApiView, type ProjectApiView } from '@/modules/projects/services/project-management.service.js';
import { scoreWorkspaceCandidates, type WorkspaceCandidate, type WorkspaceScoringChild } from '@/modules/projects/services/workspace-target-scoring.js';
import type { ProjectRepositoryRow } from '@/shared/types.js';
import { AppError, normalizeProjectPath } from '@/shared/utils.js';

export type { WorkspaceCandidate };

export type ResolveWorkspaceTargetResult = {
isWorkspace: boolean;
candidates: WorkspaceCandidate[];
};

export type DescendIntoChildResult = {
created: boolean;
project: ProjectApiView;
};

function unknownProject(projectId: string): AppError {
return new AppError(`Unknown projectId: ${projectId}`, { code: 'PROJECT_NOT_FOUND', statusCode: 404 });
}

async function hasGitEntry(directoryPath: string): Promise<boolean> {
try {
await fs.stat(path.join(directoryPath, '.git'));
return true;
} catch (error) {
const failure = error as NodeJS.ErrnoException;
if (failure.code === 'ENOENT') return false;
throw failure;
}
}

function isCandidateDirName(name: string): boolean {
return name !== 'node_modules' && !name.startsWith('.');
}

async function readPackageName(directoryPath: string): Promise<string | null> {
try {
const manifestPath = path.join(directoryPath, 'package.json');
const stats = await fs.lstat(manifestPath);
if (!stats.isFile() || stats.size > 64 * 1024) return null;
const raw = await fs.readFile(manifestPath, 'utf8');
const parsed = JSON.parse(raw) as { name?: unknown };
const name = typeof parsed.name === 'string' ? parsed.name.trim() : '';
if (!name) return null;
// Scoped packages (`@scope/name`) contribute only the unscoped segment.
return name.startsWith('@') ? name.split('/').slice(1).join('/') || null : name;
} catch {
return null;
}
}

/**
* Lists the immediate child directories of `dir` that are themselves git repositories
* (contain a `.git` entry). Hidden directories and `node_modules` are never candidates.
*/
export async function listChildRepos(dir: string, options: { withPackageNames?: boolean } = {}): Promise<WorkspaceScoringChild[]> {
let entries: import('node:fs').Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (error) {
const failure = error as NodeJS.ErrnoException;
if (failure.code === 'ENOENT' || failure.code === 'ENOTDIR') return [];
throw failure;
}

const children: WorkspaceScoringChild[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || !isCandidateDirName(entry.name)) continue;
const childPath = path.join(dir, entry.name);
if (!(await hasGitEntry(childPath))) continue;
let stats: import('node:fs').Stats;
try {
stats = await fs.stat(childPath);
} catch (error) {
const failure = error as NodeJS.ErrnoException;
if (failure.code === 'ENOENT') continue;
throw failure;
}
children.push({
path: childPath,
name: entry.name,
packageName: options.withPackageNames === false ? null : await readPackageName(childPath),
mtimeMs: stats.mtimeMs,
});
}
return children;
}

async function workspaceChildren(projectPath: string, withPackageNames: boolean): Promise<WorkspaceScoringChild[] | null> {
if (await hasGitEntry(projectPath)) return null;
const children = await listChildRepos(projectPath, { withPackageNames });
return children.length ? children : null;
}

/**
* A workspace root is a directory that is not itself a git work tree, but that
* contains at least one immediate child directory that is one.
*/
export async function isWorkspaceRoot(dir: string): Promise<boolean> {
return (await workspaceChildren(dir, false)) !== null;
}

function projectRowOrThrow(projectId: string): ProjectRepositoryRow {
const project = projectsDb.getProjectById(projectId);
if (!project) throw unknownProject(projectId);
return project;
}

export async function resolveWorkspaceTarget(projectId: string, text: string): Promise<ResolveWorkspaceTargetResult> {
const project = projectRowOrThrow(projectId);
const children = await workspaceChildren(project.project_path, true);
if (!children) {
return { isWorkspace: false, candidates: [] };
}
return { isWorkspace: true, candidates: scoreWorkspaceCandidates(text, children) };
}

function notWorkspaceChild(message: string): AppError {
return new AppError(message, { code: 'NOT_WORKSPACE_CHILD', statusCode: 400 });
}

export async function descendIntoChild(projectId: string, childPath: string): Promise<DescendIntoChildResult> {
const project = projectRowOrThrow(projectId);
const children = await workspaceChildren(project.project_path, false);
if (!children) {
throw notWorkspaceChild('Project is not a workspace root');
}
const resolvedChildPath = normalizeProjectPath(path.resolve(childPath));
const matchedChild = children.find((child) => normalizeProjectPath(child.path) === resolvedChildPath);
if (!matchedChild) {
throw notWorkspaceChild('path is not an immediate child repository of the workspace project');
}

// The user chose this repo to work in, so it becomes a sidebar project the
// same way "Add a project" does: created or un-archived as 'explicit', and a
// row the session indexer had only discovered ('auto'/'legacy') is promoted.
const { outcome, project: child } = projectsDb.createProjectPath(matchedChild.path);
if (!child) {
throw new AppError('Failed to register project for workspace child', { code: 'PROJECT_CREATE_FAILED', statusCode: 500 });
}
const row = outcome === 'active_conflict' && child.origin !== 'explicit'
? projectsDb.promoteProjectOriginById(child.project_id) ?? child
: child;
return { created: outcome === 'created', project: projectApiView(row) };
}
Loading