Save trail on the trail bar writes the walk to .codegraph/ui/trails/ as one JSON file, listed on the empty screen and on Entry points above the derived suggestions, reopened at the symbol you left with the whole path restored. A hop is stored by qualified name, kind and file — never by node id, which contains a start line and so changes the first time anybody edits above the symbol. Every hop is re-resolved against the current index on the way out and each row says what became of it: still here, moved to another file, now ambiguous, or gone. A hole is never stitched over: the row opens the longest run of CONSECUTIVE resolved hops and says which ones those are, because the trail is a path and a skipped hop would draw a call that does not exist. This is the first write the viewer makes, and the boundary moved with it: POST/DELETE answer under /api/ only, must carry X-CodeGraph-UI and application/json (neither of which a cross-origin form can produce without a preflight this server answers none of), and --read-only refuses both while still listing what is there. The blanket "read-only" claim is retired from the banner, the README, the CLI help and the docs site in favour of the narrower true one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
/**
|
|
* Where saved trails live on disk — the only thing `codegraph ui` ever writes.
|
|
*
|
|
* Every other module under `api/` is a reader. This one holds the single write
|
|
* path in the whole viewer, and it is scoped as narrowly as a write can be: one
|
|
* directory, `<CODEGRAPH_DIR>/ui/trails/`, inside the project the server was
|
|
* started on, one JSON file per trail. It never touches source, never touches
|
|
* the index, and never writes anywhere a `codegraph init` would not already
|
|
* have created. `.codegraph/.gitignore` ignores everything but itself, so a
|
|
* saved trail is local by default; exporting one to commit is a copy the reader
|
|
* makes deliberately.
|
|
*
|
|
* Two rules hold it inside the boundary described in `../security.ts`:
|
|
*
|
|
* - **The directory is resolved through `resolveProjectFile`**, exactly like a
|
|
* source read, so a trail id that tried to be a path is refused by the same
|
|
* chokepoint that refuses `?file=../../.ssh/id_rsa`. It is belt and braces on
|
|
* top of {@link isTrailId}, which already refuses anything but a slug.
|
|
* - **A write is atomic.** Temp file in the same directory, then rename. A
|
|
* half-written trail read back by the list would look like a corrupt one, and
|
|
* the list would then have to decide whether to hide it — which is a decision
|
|
* nobody should have to make about a file they saved a second ago.
|
|
*
|
|
* The format is deliberately plain: a reader can open one in an editor, and a
|
|
* hop is described by what it IS (a qualified name in a file) rather than by the
|
|
* node id it happened to have. Node ids contain a start line, so any edit above
|
|
* a symbol renames it — a trail keyed on ids would not survive its own project.
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { CODEGRAPH_DIR } from '../../directory';
|
|
import { resolveProjectFile } from '../security';
|
|
import { ApiError, badRequest } from './respond';
|
|
|
|
/** Where trails live, relative to the project root. Forward slashes always. */
|
|
export const TRAILS_RELATIVE_DIR = `${CODEGRAPH_DIR}/ui/trails`;
|
|
|
|
/** The only `version` this build writes, and the only one it reads. */
|
|
export const TRAIL_FORMAT_VERSION = 1;
|
|
|
|
/** Trail files read from the directory before the list stops looking. */
|
|
export const MAX_TRAILS = 200;
|
|
|
|
/** Hops one trail may carry. Past this it is a history, not a tour. */
|
|
export const MAX_TRAIL_HOPS = 64;
|
|
|
|
/** Characters in a trail's name. */
|
|
export const MAX_TRAIL_NAME = 120;
|
|
|
|
/** Characters in a trail's note. */
|
|
export const MAX_TRAIL_NOTE = 600;
|
|
|
|
/** Bytes a single trail file may be before it is skipped as not-ours. */
|
|
export const MAX_TRAIL_FILE_BYTES = 64 * 1024;
|
|
|
|
/** Characters in a generated slug, before any de-duplicating suffix. */
|
|
const MAX_SLUG = 60;
|
|
|
|
/** How a reader got from the previous hop to this one. Mirrors the viewer's `HopDirection`. */
|
|
export type StoredHopDirection = 'start' | 'down' | 'up';
|
|
|
|
/**
|
|
* One hop, described by what it is rather than by the id it had.
|
|
*
|
|
* `id` is kept as a HINT — when the file has not changed it resolves in one
|
|
* lookup — but `qualifiedName` + `kind` + `file` is what the trail is actually
|
|
* keyed on, and what lets it survive a re-index.
|
|
*/
|
|
export interface StoredHop {
|
|
dir: StoredHopDirection;
|
|
name: string;
|
|
qualifiedName: string;
|
|
kind: string;
|
|
/** Project-relative, forward slashes. */
|
|
file: string;
|
|
line: number;
|
|
/** The node id at save time. A fast path, never the identity. */
|
|
id: string;
|
|
}
|
|
|
|
export interface StoredTrail {
|
|
version: number;
|
|
/** Slug, and the file's basename. */
|
|
id: string;
|
|
name: string;
|
|
note: string;
|
|
/** Whoever saved it — git's `user.name`, or the OS user. */
|
|
author: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
hops: StoredHop[];
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ paths -- */
|
|
|
|
/**
|
|
* Whether a string is a trail id we would have written.
|
|
*
|
|
* Lowercase slug characters only: no dot, no separator, no leading dash. This
|
|
* is what makes `<id>.json` a filename rather than a path expression, and it
|
|
* runs before the id is ever joined to anything.
|
|
*/
|
|
export function isTrailId(value: string): boolean {
|
|
return /^[a-z0-9][a-z0-9-]{0,79}$/.test(value);
|
|
}
|
|
|
|
/**
|
|
* `Read a file with these lines` -> `read-a-file-with-these-lines`.
|
|
*
|
|
* Names that carry no ASCII letters or digits at all (a trail named entirely in
|
|
* Chinese, or in emoji) slug to nothing; they get `trail`, and the collision
|
|
* handling in {@link saveTrail} keeps them distinct from each other.
|
|
*/
|
|
export function slugify(name: string): string {
|
|
const slug = name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, MAX_SLUG)
|
|
.replace(/-+$/g, '');
|
|
return slug === '' ? 'trail' : slug;
|
|
}
|
|
|
|
/** The absolute trails directory, having been through the read chokepoint. */
|
|
export function trailsDirectory(projectRoot: string): string {
|
|
return resolveProjectFile(projectRoot, TRAILS_RELATIVE_DIR);
|
|
}
|
|
|
|
/**
|
|
* The absolute path of one trail file.
|
|
*
|
|
* @throws {ApiError} `bad-request` when the id is not a slug we would have
|
|
* written — checked before the join, so nothing path-shaped is ever built.
|
|
*/
|
|
export function trailPath(projectRoot: string, id: string): string {
|
|
if (!isTrailId(id)) {
|
|
throw badRequest(
|
|
`"${id}" is not a saved trail id.`,
|
|
'Trail ids are the lowercase slug in the file name, e.g. "how-a-request-is-served".'
|
|
);
|
|
}
|
|
return resolveProjectFile(projectRoot, `${TRAILS_RELATIVE_DIR}/${id}.json`);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------- read -- */
|
|
|
|
/**
|
|
* Parse a file into a trail, or `null` if it is not one.
|
|
*
|
|
* Everything is re-validated rather than trusted: the directory is a place a
|
|
* user may hand-edit a file, or drop one somebody else exported, and a trail
|
|
* that half-parsed would draw a row with holes in it. A file that fails is
|
|
* skipped and counted, never repaired in place.
|
|
*/
|
|
export function parseTrail(id: string, text: string): StoredTrail | null {
|
|
let raw: unknown;
|
|
try {
|
|
raw = JSON.parse(text);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (typeof raw !== 'object' || raw === null) return null;
|
|
const value = raw as Record<string, unknown>;
|
|
if (typeof value.name !== 'string' || value.name.trim() === '') return null;
|
|
if (!Array.isArray(value.hops) || value.hops.length === 0) return null;
|
|
|
|
const hops: StoredHop[] = [];
|
|
for (const entry of value.hops.slice(0, MAX_TRAIL_HOPS)) {
|
|
if (typeof entry !== 'object' || entry === null) return null;
|
|
const hop = entry as Record<string, unknown>;
|
|
const qualifiedName = typeof hop.qualifiedName === 'string' ? hop.qualifiedName : '';
|
|
const name = typeof hop.name === 'string' ? hop.name : '';
|
|
if (qualifiedName === '' && name === '') return null;
|
|
hops.push({
|
|
dir: hop.dir === 'up' || hop.dir === 'down' ? hop.dir : 'start',
|
|
name: name || qualifiedName,
|
|
qualifiedName: qualifiedName || name,
|
|
kind: typeof hop.kind === 'string' ? hop.kind : '',
|
|
file: typeof hop.file === 'string' ? hop.file : '',
|
|
line: typeof hop.line === 'number' && hop.line > 0 ? Math.floor(hop.line) : 0,
|
|
id: typeof hop.id === 'string' ? hop.id : '',
|
|
});
|
|
}
|
|
|
|
const created = typeof value.createdAt === 'string' ? value.createdAt : '';
|
|
return {
|
|
version: typeof value.version === 'number' ? value.version : TRAIL_FORMAT_VERSION,
|
|
// The FILE's name wins over any `id` inside it: the basename is what the
|
|
// delete route addresses, so a hand-copied file is addressable under the
|
|
// name it actually has rather than the one it remembers having.
|
|
id,
|
|
name: value.name.slice(0, MAX_TRAIL_NAME),
|
|
note: typeof value.note === 'string' ? value.note.slice(0, MAX_TRAIL_NOTE) : '',
|
|
author: typeof value.author === 'string' ? value.author.slice(0, 120) : '',
|
|
createdAt: created,
|
|
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : created,
|
|
hops,
|
|
};
|
|
}
|
|
|
|
export interface StoredTrailList {
|
|
trails: StoredTrail[];
|
|
/** Files in the directory that were not readable trails. */
|
|
skipped: number;
|
|
}
|
|
|
|
/**
|
|
* Every trail in the project, newest save first.
|
|
*
|
|
* A missing directory is the ordinary state of a project nobody has saved a
|
|
* trail in — an empty list, never an error.
|
|
*/
|
|
export function listStoredTrails(projectRoot: string): StoredTrailList {
|
|
const dir = trailsDirectory(projectRoot);
|
|
let names: string[];
|
|
try {
|
|
names = fs.readdirSync(dir);
|
|
} catch {
|
|
return { trails: [], skipped: 0 };
|
|
}
|
|
|
|
const trails: StoredTrail[] = [];
|
|
let skipped = 0;
|
|
for (const name of names.sort()) {
|
|
if (!name.endsWith('.json')) continue;
|
|
if (trails.length >= MAX_TRAILS) break;
|
|
const id = name.slice(0, -'.json'.length);
|
|
if (!isTrailId(id)) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
const trail = readTrailFile(path.join(dir, name), id);
|
|
if (trail) trails.push(trail);
|
|
else skipped += 1;
|
|
}
|
|
|
|
// Newest save first: a tour written a minute ago is the one being iterated on.
|
|
trails.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : a.name.localeCompare(b.name)));
|
|
return { trails, skipped };
|
|
}
|
|
|
|
function readTrailFile(absolute: string, id: string): StoredTrail | null {
|
|
try {
|
|
const stat = fs.statSync(absolute);
|
|
// A file too big to be a trail is skipped rather than read: this directory
|
|
// is inside the project, and something else may one day put a log in it.
|
|
if (!stat.isFile() || stat.size > MAX_TRAIL_FILE_BYTES) return null;
|
|
return parseTrail(id, fs.readFileSync(absolute, 'utf-8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** One trail by id, or `null` when there is no such file. */
|
|
export function readStoredTrail(projectRoot: string, id: string): StoredTrail | null {
|
|
return readTrailFile(trailPath(projectRoot, id), id);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ write -- */
|
|
|
|
/**
|
|
* Write a trail, atomically.
|
|
*
|
|
* Temp file beside the target then `rename`, so a reader either sees the
|
|
* previous trail or the new one and never a partial file. The temp name carries
|
|
* the pid: two `codegraph ui` processes on one project is unusual but not
|
|
* forbidden, and two writers sharing a temp name would corrupt each other's.
|
|
*/
|
|
export function writeStoredTrail(projectRoot: string, trail: StoredTrail): void {
|
|
const dir = trailsDirectory(projectRoot);
|
|
try {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
} catch (err) {
|
|
throw writeFailure(err);
|
|
}
|
|
const target = trailPath(projectRoot, trail.id);
|
|
const temp = `${target}.${process.pid}.tmp`;
|
|
try {
|
|
fs.writeFileSync(temp, `${JSON.stringify(trail, null, 2)}\n`, 'utf-8');
|
|
fs.renameSync(temp, target);
|
|
} catch (err) {
|
|
try {
|
|
fs.unlinkSync(temp);
|
|
} catch {
|
|
// Nothing to clean up, or nothing we can do about it. The write already
|
|
// failed; the caller is about to be told so.
|
|
}
|
|
throw writeFailure(err);
|
|
}
|
|
}
|
|
|
|
/** Remove a trail. Returns false when there was nothing there. */
|
|
export function deleteStoredTrail(projectRoot: string, id: string): boolean {
|
|
try {
|
|
fs.unlinkSync(trailPath(projectRoot, id));
|
|
return true;
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
|
throw writeFailure(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An id nothing in `taken` is using, preferring the plain slug.
|
|
*
|
|
* A save under a name that is already there REPLACES it — that is what a reader
|
|
* pressing Save with the same name means — so the caller passes the ids of
|
|
* trails carrying a *different* name, and this only steps aside for those.
|
|
*/
|
|
export function uniqueTrailId(base: string, taken: ReadonlySet<string>): string {
|
|
if (!taken.has(base)) return base;
|
|
for (let n = 2; n < 1000; n += 1) {
|
|
const candidate = `${base}-${n}`;
|
|
if (!taken.has(candidate)) return candidate;
|
|
}
|
|
// 999 trails sharing one slug is not a state worth a clever answer.
|
|
throw new ApiError('bad-request', `Too many saved trails are already named like "${base}".`);
|
|
}
|
|
|
|
function writeFailure(err: unknown): ApiError {
|
|
const code = (err as NodeJS.ErrnoException).code;
|
|
const detail = err instanceof Error ? err.message : String(err);
|
|
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
|
|
return new ApiError(
|
|
'refused',
|
|
`Saved trails could not be written: ${detail}`,
|
|
`The viewer writes only to ${TRAILS_RELATIVE_DIR} inside this project. Check that it is writable.`
|
|
);
|
|
}
|
|
return new ApiError('internal', `Saved trails could not be written: ${detail}`);
|
|
}
|