Skip to content

Commit 5d21c8c

Browse files
authored
Merge pull request #17 from lee98www/feat/workspace-quick-task
feat(projects): start a task at a workspace root and resolve the child repo from the first message
2 parents a14e454 + 66a35ae commit 5d21c8c

29 files changed

Lines changed: 1394 additions & 9 deletions

CLA.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ Signed-off by: <name> <email> — GitHub @<handle> — YYYY-MM-DD
9595
### Signatories
9696

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

99100
---
100101

server/index.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import gitRoutes from './routes/git.js';
4747
import authRoutes from './routes/auth.js';
4848
import settingsRoutes from './routes/settings.js';
4949
import { createGjcAppFactory } from './app-factory.js';
50+
import { isWorkspaceRoot } from './modules/projects/index.js';
5051
import projectModuleRoutes from './modules/projects/projects.routes.js';
5152
import notificationRoutes from './modules/notifications/notifications.routes.js';
5253
import userRoutes from './routes/user.js';
@@ -577,7 +578,13 @@ app.get('/api/projects/:projectId/files', authenticateToken, async (req, res) =>
577578
return res.status(404).json({ error: `Project path not found: ${actualPath}` });
578579
}
579580

580-
const files = await getFileTree(actualPath, 10, 0, true);
581+
// A workspace root (~/Projects: no repo of its own, dozens of child
582+
// repos) is where a session picks a child repo, not a tree to mention
583+
// files from. Walking it ten levels deep stats every file in every
584+
// repo and pins the event loop for minutes, so it lists the root's own
585+
// entries but does not open any child repo.
586+
const depth = (await isWorkspaceRoot(actualPath)) ? 0 : 10;
587+
const files = await getFileTree(actualPath, depth, 0, true);
581588
res.json(files);
582589
} catch (error) {
583590
console.error('[ERROR] File tree error:', error.message);

server/modules/projects/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import {
1414
generateDisplayName as displayName,
1515
getProjectsWithSessions as projectsWithSessions,
1616
} from './services/projects-with-sessions-fetch.service.js';
17+
import { isWorkspaceRoot as workspaceRoot } from './services/workspace-target.service.js';
1718

1819
export {
20+
workspaceRoot as isWorkspaceRoot,
1921
displayName as generateDisplayName,
2022
grantAlwaysAllow as grantProjectAlwaysAllow,
2123
projectsWithSessions as getProjectsWithSessions,

server/modules/projects/projects.routes.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import express from 'express';
33
import { deleteOrArchiveProject, restoreArchivedProject } from '@/modules/projects/services/project-delete.service.js';
44
import { startCloneProject, type CloneProjectOperation } from '@/modules/projects/services/project-clone.service.js';
55
import { createProject, promoteProjectOrigin, updateProjectDisplayName } from '@/modules/projects/services/project-management.service.js';
6+
import { descendIntoChild, resolveWorkspaceTarget } from '@/modules/projects/services/workspace-target.service.js';
67
import {
78
getProjectPermissions,
89
listConfiguredProjectPermissions,
@@ -95,6 +96,21 @@ router.post('/:projectId/promote', asyncHandler(async (request, response) => {
9596
response.json({ success: true, project: promoteProjectOrigin(projectId) });
9697
}));
9798

99+
router.get('/:projectId/resolve-target', asyncHandler(async (request, response) => {
100+
const projectId = routeProjectId(request.params.projectId, true);
101+
const result = await resolveWorkspaceTarget(projectId, queryText(request.query.text));
102+
response.json(createApiSuccessResponse(result));
103+
}));
104+
105+
router.post('/:projectId/descend', asyncHandler(async (request, response) => {
106+
const projectId = routeProjectId(request.params.projectId, true);
107+
const body: { path?: unknown } = request.body ?? {};
108+
const childPath = typeof body.path === 'string' ? body.path : '';
109+
if (!childPath) throw new AppError('path is required', { code: 'NOT_WORKSPACE_CHILD', statusCode: 400 });
110+
const { created, project } = await descendIntoChild(projectId, childPath);
111+
response.status(created ? 201 : 200).json(createApiSuccessResponse(project));
112+
}));
113+
98114
router.get('/:projectId/sessions', asyncHandler(async (request, response) => {
99115
const sessions = await getProjectSessionsPage(routeProjectId(request.params.projectId), {
100116
limit: nonNegativeQueryNumber(request.query.limit, 'limit', 20),

server/modules/projects/services/project-management.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const projectStore: CreateProjectDependencies = {
4141
validatePath: validateWorkspacePath, // shared workspace gate from utils
4242
};
4343

44-
function projectApiView(project: ProjectRepositoryRow): ProjectApiView {
44+
export function projectApiView(project: ProjectRepositoryRow): ProjectApiView {
4545
const fullPath = project.project_path;
4646
const view: ProjectApiView = {
4747
projectId: project.project_id,
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
export type WorkspaceCandidateReason = 'mention' | 'partial' | 'recent';
2+
3+
export type WorkspaceCandidate = {
4+
path: string;
5+
name: string;
6+
score: number;
7+
reason: WorkspaceCandidateReason;
8+
};
9+
10+
export type WorkspaceScoringChild = {
11+
path: string;
12+
name: string;
13+
packageName: string | null;
14+
mtimeMs: number;
15+
};
16+
17+
const TOKEN_CHARACTER = /[a-z0-9._-]/;
18+
19+
function candidateNames(child: WorkspaceScoringChild): string[] {
20+
const names = [child.name.toLowerCase(), child.packageName?.toLowerCase() ?? null].filter(
21+
(name): name is string => Boolean(name),
22+
);
23+
return Array.from(new Set(names));
24+
}
25+
26+
// `name` occurs in the text with nothing token-like on either side, so a repo named
27+
// `gajae-code` is not "mentioned" by a message about `gajae-code-app`. This is how a
28+
// name made of characters outside the token class (a Korean directory) gets found.
29+
function mentionedWhole(name: string, lowerText: string): boolean {
30+
let from = 0;
31+
for (;;) {
32+
const at = lowerText.indexOf(name, from);
33+
if (at === -1) return false;
34+
const before = at === 0 ? '' : lowerText[at - 1];
35+
const after = lowerText[at + name.length] ?? '';
36+
if (!TOKEN_CHARACTER.test(before) && !TOKEN_CHARACTER.test(after)) return true;
37+
from = at + 1;
38+
}
39+
}
40+
41+
function scoreAgainstName(name: string, lowerText: string, tokens: string[]): { score: number; reason: WorkspaceCandidateReason } {
42+
if (tokens.includes(name)) return { score: 100, reason: 'mention' };
43+
// Names the ASCII tokenizer would mangle (Korean directories or symbols like
44+
// c++) can only be found by whole mention; boundaries keep short ASCII names
45+
// such as "go" from false-positiving.
46+
if ((name.length >= 4 || (name.length >= 2 && /[^a-z0-9._-]/.test(name))) && mentionedWhole(name, lowerText)) return { score: 80, reason: 'mention' };
47+
if (tokens.some((token) => token.length >= 3 && name.startsWith(token))) return { score: 40, reason: 'partial' };
48+
return { score: 0, reason: 'recent' };
49+
}
50+
51+
/**
52+
* Ranks every child repository of a workspace against free-form task text. Each child
53+
* scores by its best match across the directory name and the (scope-stripped)
54+
* package.json name; ties, and all children when the text is empty, fall back to
55+
* directory recency. The whole list comes back so a picker can show every repo, with
56+
* the likely target first.
57+
*/
58+
export function scoreWorkspaceCandidates(text: string, children: WorkspaceScoringChild[]): WorkspaceCandidate[] {
59+
const lowerText = text.trim().toLowerCase();
60+
const tokens = lowerText.split(/[^a-z0-9._-]+/).filter(Boolean);
61+
62+
const scored = children.map((child) => {
63+
let best = { score: 0, reason: 'recent' as WorkspaceCandidateReason };
64+
for (const name of candidateNames(child)) {
65+
const candidate = scoreAgainstName(name, lowerText, tokens);
66+
if (candidate.score > best.score) best = candidate;
67+
}
68+
return { path: child.path, name: child.name, score: best.score, reason: best.reason, mtimeMs: child.mtimeMs };
69+
});
70+
71+
scored.sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs);
72+
return scored.map(({ path, name, score, reason }) => ({ path, name, score, reason }));
73+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import fs from 'node:fs/promises';
2+
import path from 'node:path';
3+
4+
import { projectsDb } from '@/modules/database/index.js';
5+
import { projectApiView, type ProjectApiView } from '@/modules/projects/services/project-management.service.js';
6+
import { scoreWorkspaceCandidates, type WorkspaceCandidate, type WorkspaceScoringChild } from '@/modules/projects/services/workspace-target-scoring.js';
7+
import type { ProjectRepositoryRow } from '@/shared/types.js';
8+
import { AppError, normalizeProjectPath } from '@/shared/utils.js';
9+
10+
export type { WorkspaceCandidate };
11+
12+
export type ResolveWorkspaceTargetResult = {
13+
isWorkspace: boolean;
14+
candidates: WorkspaceCandidate[];
15+
};
16+
17+
export type DescendIntoChildResult = {
18+
created: boolean;
19+
project: ProjectApiView;
20+
};
21+
22+
function unknownProject(projectId: string): AppError {
23+
return new AppError(`Unknown projectId: ${projectId}`, { code: 'PROJECT_NOT_FOUND', statusCode: 404 });
24+
}
25+
26+
async function hasGitEntry(directoryPath: string): Promise<boolean> {
27+
try {
28+
await fs.stat(path.join(directoryPath, '.git'));
29+
return true;
30+
} catch (error) {
31+
const failure = error as NodeJS.ErrnoException;
32+
if (failure.code === 'ENOENT') return false;
33+
throw failure;
34+
}
35+
}
36+
37+
function isCandidateDirName(name: string): boolean {
38+
return name !== 'node_modules' && !name.startsWith('.');
39+
}
40+
41+
async function readPackageName(directoryPath: string): Promise<string | null> {
42+
try {
43+
const manifestPath = path.join(directoryPath, 'package.json');
44+
const stats = await fs.lstat(manifestPath);
45+
if (!stats.isFile() || stats.size > 64 * 1024) return null;
46+
const raw = await fs.readFile(manifestPath, 'utf8');
47+
const parsed = JSON.parse(raw) as { name?: unknown };
48+
const name = typeof parsed.name === 'string' ? parsed.name.trim() : '';
49+
if (!name) return null;
50+
// Scoped packages (`@scope/name`) contribute only the unscoped segment.
51+
return name.startsWith('@') ? name.split('/').slice(1).join('/') || null : name;
52+
} catch {
53+
return null;
54+
}
55+
}
56+
57+
/**
58+
* Lists the immediate child directories of `dir` that are themselves git repositories
59+
* (contain a `.git` entry). Hidden directories and `node_modules` are never candidates.
60+
*/
61+
export async function listChildRepos(dir: string, options: { withPackageNames?: boolean } = {}): Promise<WorkspaceScoringChild[]> {
62+
let entries: import('node:fs').Dirent[];
63+
try {
64+
entries = await fs.readdir(dir, { withFileTypes: true });
65+
} catch (error) {
66+
const failure = error as NodeJS.ErrnoException;
67+
if (failure.code === 'ENOENT' || failure.code === 'ENOTDIR') return [];
68+
throw failure;
69+
}
70+
71+
const children: WorkspaceScoringChild[] = [];
72+
for (const entry of entries) {
73+
if (!entry.isDirectory() || !isCandidateDirName(entry.name)) continue;
74+
const childPath = path.join(dir, entry.name);
75+
if (!(await hasGitEntry(childPath))) continue;
76+
let stats: import('node:fs').Stats;
77+
try {
78+
stats = await fs.stat(childPath);
79+
} catch (error) {
80+
const failure = error as NodeJS.ErrnoException;
81+
if (failure.code === 'ENOENT') continue;
82+
throw failure;
83+
}
84+
children.push({
85+
path: childPath,
86+
name: entry.name,
87+
packageName: options.withPackageNames === false ? null : await readPackageName(childPath),
88+
mtimeMs: stats.mtimeMs,
89+
});
90+
}
91+
return children;
92+
}
93+
94+
async function workspaceChildren(projectPath: string, withPackageNames: boolean): Promise<WorkspaceScoringChild[] | null> {
95+
if (await hasGitEntry(projectPath)) return null;
96+
const children = await listChildRepos(projectPath, { withPackageNames });
97+
return children.length ? children : null;
98+
}
99+
100+
/**
101+
* A workspace root is a directory that is not itself a git work tree, but that
102+
* contains at least one immediate child directory that is one.
103+
*/
104+
export async function isWorkspaceRoot(dir: string): Promise<boolean> {
105+
return (await workspaceChildren(dir, false)) !== null;
106+
}
107+
108+
function projectRowOrThrow(projectId: string): ProjectRepositoryRow {
109+
const project = projectsDb.getProjectById(projectId);
110+
if (!project) throw unknownProject(projectId);
111+
return project;
112+
}
113+
114+
export async function resolveWorkspaceTarget(projectId: string, text: string): Promise<ResolveWorkspaceTargetResult> {
115+
const project = projectRowOrThrow(projectId);
116+
const children = await workspaceChildren(project.project_path, true);
117+
if (!children) {
118+
return { isWorkspace: false, candidates: [] };
119+
}
120+
return { isWorkspace: true, candidates: scoreWorkspaceCandidates(text, children) };
121+
}
122+
123+
function notWorkspaceChild(message: string): AppError {
124+
return new AppError(message, { code: 'NOT_WORKSPACE_CHILD', statusCode: 400 });
125+
}
126+
127+
export async function descendIntoChild(projectId: string, childPath: string): Promise<DescendIntoChildResult> {
128+
const project = projectRowOrThrow(projectId);
129+
const children = await workspaceChildren(project.project_path, false);
130+
if (!children) {
131+
throw notWorkspaceChild('Project is not a workspace root');
132+
}
133+
const resolvedChildPath = normalizeProjectPath(path.resolve(childPath));
134+
const matchedChild = children.find((child) => normalizeProjectPath(child.path) === resolvedChildPath);
135+
if (!matchedChild) {
136+
throw notWorkspaceChild('path is not an immediate child repository of the workspace project');
137+
}
138+
139+
// The user chose this repo to work in, so it becomes a sidebar project the
140+
// same way "Add a project" does: created or un-archived as 'explicit', and a
141+
// row the session indexer had only discovered ('auto'/'legacy') is promoted.
142+
const { outcome, project: child } = projectsDb.createProjectPath(matchedChild.path);
143+
if (!child) {
144+
throw new AppError('Failed to register project for workspace child', { code: 'PROJECT_CREATE_FAILED', statusCode: 500 });
145+
}
146+
const row = outcome === 'active_conflict' && child.origin !== 'explicit'
147+
? projectsDb.promoteProjectOriginById(child.project_id) ?? child
148+
: child;
149+
return { created: outcome === 'created', project: projectApiView(row) };
150+
}

0 commit comments

Comments
 (0)