Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,15 @@ export function FieldRow({
field,
typeNames,
readOnly,
disableRemove,
onChange,
onRemove,
}: {
field: FieldModel;
typeNames: string[];
readOnly: boolean;
/** True when this is the table's only field: a GraphQL type needs at least one, so removal is blocked. */
disableRemove?: boolean;
onChange: (field: FieldModel) => void;
onRemove: () => void;
}) {
Expand Down Expand Up @@ -107,9 +110,9 @@ export function FieldRow({
variant="destructiveGhost"
size="icon"
className="self-end"
disabled={readOnly}
disabled={readOnly || disableRemove}
onClick={onRemove}
title="Remove field"
title={disableRemove ? 'A table needs at least one field' : 'Remove field'}
>
<TrashIcon />
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export function TableCard({
{fieldCount} field{fieldCount === 1 ? '' : 's'}
{exported ? ' · REST' : ''}
{nameError ? ' · ⚠ invalid name' : ''}
{fieldCount === 0 ? ' · ⚠ needs a field' : ''}
</span>
</button>
<Button
Expand Down Expand Up @@ -229,13 +230,19 @@ export function TableCard({

<div>
<h4 className="mb-2 text-sm font-medium">Fields</h4>
{fieldCount === 0 && (
<p className="mb-2 text-xs text-destructive">
A table needs at least one field to be valid. Add one below.
</p>
)}
<div className="flex flex-col gap-2">
{table.fields.map((field, index) => (
<FieldRow
key={field.key ?? index}
field={field}
typeNames={typeNames}
readOnly={readOnly}
disableRemove={fieldCount === 1}
onChange={next =>
onChange({ ...table, fields: table.fields.map((f, i) => i === index ? next : f) })}
onRemove={() => onChange({ ...table, fields: table.fields.filter((_, i) => i !== index) })}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function createTable(id: string): TableModel {
directives: [{ name: 'primaryKey', args: [], hadParens: false }],
},
],
trailingComments: [],
raw: '',
edited: true,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ describe('parseSchema round-trip fidelity', () => {
);
});

it('round-trips a comment trailing the last field, before the closing brace', () => {
expectRoundTrip('type Dog @table {\n\tid: ID @primaryKey\n\t# trailing note about the table\n}\n');
});

it('round-trips a comment on the type header line, after the opening brace', () => {
expectRoundTrip('type Dog @table { # the dogs table\n\tid: ID @primaryKey\n}\n');
});

it('leaves an empty document empty', () => {
expectRoundTrip('');
});
Expand Down Expand Up @@ -98,6 +106,24 @@ describe('parseSchema structure', () => {
expect(t.fields[0].leadingComments).toEqual(['# the id']);
expect(t.fields[0].lineComment).toBe('# inline');
});

it('captures a comment trailing the last field as trailingComments, not a leading comment', () => {
const [t] = tables('type T @table {\n\tid: ID @primaryKey\n\t# trailing note\n}\n');
expect(t.fields[0].leadingComments).toEqual([]);
expect(t.trailingComments).toEqual(['# trailing note']);
});

it('captures a header-line comment separately from the first field', () => {
const [t] = tables('type T @table { # header note\n\tid: ID @primaryKey\n}\n');
expect(t.headerComment).toBe('# header note');
expect(t.fields[0].leadingComments).toEqual([]);
});

it("keeps a comment on its own line before the first field as that field's leading comment", () => {
const [t] = tables('type T @table {\n\t# not a header comment\n\tid: ID @primaryKey\n}\n');
expect(t.headerComment).toBeUndefined();
expect(t.fields[0].leadingComments).toEqual(['# not a header comment']);
});
});

describe('parseSchema failure handling', () => {
Expand Down
46 changes: 40 additions & 6 deletions src/features/instance/applications/lib/schema/parseSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,8 +347,12 @@ function skipInlineWhitespace(src: string, i: number): number {
return j;
}

/** Parse the fields between a type body's braces; return null if anything is malformed. */
function parseFields(body: string): FieldModel[] | null {
/**
* Parse the fields between a type body's braces; return null if anything is
* malformed. Comment/description lines that trail the last field (no field
* follows them) are returned as `trailingComments` so they survive regeneration.
*/
function parseFields(body: string): { fields: FieldModel[]; trailingComments: string[] } | null {
const fields: FieldModel[] = [];
let pendingComments: string[] = [];
let i = 0;
Expand Down Expand Up @@ -429,7 +433,26 @@ function parseFields(body: string): FieldModel[] | null {
});
pendingComments = [];
}
return fields;
// Anything still pending never found a field to attach to — it's trailing
// trivia inside the body (after the last field, before `}`).
return { fields, trailingComments: pendingComments };
}

/**
* Peel a header-line `# …` comment off the body. `body` is the text after the
* opening `{`, so its first line is the remainder of the `type … {` line; if that
* line is a comment it belongs to the header, not the first field.
*/
function splitHeaderComment(body: string): { headerComment?: string; body: string } {
const newlineIndex = body.indexOf('\n');
if (newlineIndex < 0) {
return { body };
}
const firstLine = body.slice(0, newlineIndex);
if (firstLine.replace(/^[ \t,]+/, '').startsWith('#')) {
return { headerComment: firstLine.replace(/\r$/, '').trim(), body: body.slice(newlineIndex + 1) };
}
return { body };
}

/* -------------------------------------------------------------------------- */
Expand Down Expand Up @@ -459,11 +482,22 @@ function parseTypeBlock(blockSrc: string, id: string): TableModel | null {
if (bodyEnd < braceStart) {
return null;
}
const fields = parseFields(blockSrc.slice(braceStart + 1, bodyEnd));
if (!fields) {
const { headerComment, body } = splitHeaderComment(blockSrc.slice(braceStart + 1, bodyEnd));
const parsed = parseFields(body);
if (!parsed) {
return null;
}
return { id, leading: '', typeName, directives, fields, raw: blockSrc, edited: false };
return {
id,
leading: '',
typeName,
headerComment,
directives,
fields: parsed.fields,
trailingComments: parsed.trailingComments,
raw: blockSrc,
edited: false,
};
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,40 @@ describe('serializeSchema description (""") handling', () => {
});
});

describe('serializeSchema preserves in-body comments when a table is edited', () => {
it('keeps a comment trailing the last field instead of dropping it on edit', () => {
const source = 'type Dog @table {\n\tid: ID @primaryKey\n\t# trailing note about the table\n}\n';
const doc = parse(source);
firstTable(doc).edited = true;
expect(serializeSchema(doc)).toBe(source);
});

it('keeps a header-line comment on the header line instead of relocating it on edit', () => {
const source = 'type Dog @table { # the dogs table\n\tid: ID @primaryKey\n}\n';
const doc = parse(source);
firstTable(doc).edited = true;
expect(serializeSchema(doc)).toBe(source);
});

it('preserves a comments-only table body on edit (does not silently drop the comment)', () => {
const source = 'type Dog @table {\n\t# just a note, no fields yet\n}\n';
const doc = parse(source);
firstTable(doc).edited = true;
expect(serializeSchema(doc)).toContain('# just a note, no fields yet');
});

it('re-editing a table with a trailing multi-line description block stays idempotent', () => {
const source = 'type Dog @table {\n\tid: ID @primaryKey\n\t"""\n\tA trailing note.\n\tOn two lines.\n\t"""\n}\n';
const doc = parse(source);
firstTable(doc).edited = true;
const once = serializeSchema(doc);
expect(once).toBe(source);
const twice = parse(once);
firstTable(twice).edited = true;
expect(serializeSchema(twice)).toBe(source);
});
});

describe('serializeSchema canonical generation for edited tables', () => {
it('regenerates directives and args in canonical order, preserving unknown ones', () => {
const doc = parse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,24 @@ function serializeDirectives(directives: Directive[], order: string[]): string {
.join(' ');
}

/** Re-indent a stored comment/description block to the canonical indent, one line at a time. */
function indentCommentLines(comment: string, indent: string): string[] {
return comment.split('\n').map(rawLine => {
// Strip any indentation the line already carried (e.g. interior lines of a
// multi-line """description""") before applying the canonical indent, so
// re-editing a table doesn't compound the indentation each time. Blank
// lines stay blank.
const line = rawLine.replace(/\r$/, '').replace(/^[ \t]+/, '');
return line ? `${indent}${line}` : '';
});
}

/** Generate the canonical `type … { … }` text for an edited or new table. */
function generateTable(table: TableModel, doc: SchemaDocument): string {
const { indent, newline } = doc;
const directives = serializeDirectives(table.directives, TABLE_DIRECTIVE_ORDER);
const lines: string[] = [`type ${table.typeName}${directives ? ` ${directives}` : ''} {`];
const header = `type ${table.typeName}${directives ? ` ${directives}` : ''} {`;
const lines: string[] = [table.headerComment ? `${header} ${table.headerComment}` : header];

for (const field of table.fields) {
// An added-but-unnamed field would emit invalid SDL (`\t: String`); skip it
Expand All @@ -73,20 +86,19 @@ function generateTable(table: TableModel, doc: SchemaDocument): string {
continue;
}
for (const comment of field.leadingComments) {
for (const rawLine of comment.split('\n')) {
// Strip any indentation the line already carried (e.g. interior lines of a
// multi-line """description""") before applying the canonical indent, so
// re-editing a table doesn't compound the indentation each time. Blank
// lines stay blank.
const line = rawLine.replace(/\r$/, '').replace(/^[ \t]+/, '');
lines.push(line ? `${indent}${line}` : '');
}
lines.push(...indentCommentLines(comment, indent));
}
const fieldDirectives = serializeDirectives(field.directives, FIELD_DIRECTIVE_ORDER);
const suffix = [fieldDirectives, field.lineComment].filter(Boolean).join(' ');
lines.push(`${indent}${field.name}: ${formatTypeRef(field.type)}${suffix ? ` ${suffix}` : ''}`);
}

// Comments that trailed the last field, before `}` — preserved so an edit
// doesn't silently drop them.
for (const comment of table.trailingComments) {
lines.push(...indentCommentLines(comment, indent));
}

return `${lines.join(newline)}${newline}}`;
}

Expand Down
13 changes: 13 additions & 0 deletions src/features/instance/applications/lib/schema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,21 @@ export interface TableModel {
*/
leading: string;
typeName: string;
/**
* A `# …` comment written on the type's header line, right after the opening
* brace (e.g. `type Dog @table { # note`). Captured so it isn't relocated to a
* standalone line above the first field when the table is regenerated.
*/
headerComment?: string;
directives: Directive[];
fields: FieldModel[];
/**
* Comment (`# …`) and description (`"""…"""`) lines that sit inside the body
* after the last field, before the closing `}` — each captured verbatim
* (without the structural indent). Re-emitted before `}` when the table is
* regenerated so trailing trivia isn't silently dropped on edit.
*/
trailingComments: string[];
/**
* The exact original source of `type … { … }` (excluding {@link leading}).
* Emitted verbatim while {@link edited} is false, guaranteeing untouched
Expand Down