@gaia-ai/addon-gaia-ui 0.11.1 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/Agent/Component/agent-list/index.d.ts +6 -0
- package/dist/src/Agent/Component/agent-list/index.js +14 -0
- package/dist/src/Component/agent-list/index.d.ts +6 -0
- package/dist/src/Component/agent-list/index.js +14 -0
- package/dist/src/Component/comment-item/index.d.ts +21 -0
- package/dist/src/Component/comment-item/index.js +39 -0
- package/dist/src/Component/create-form/index.d.ts +3 -0
- package/dist/src/Component/create-form/index.js +26 -0
- package/dist/src/Component/dashboard-list/index.d.ts +8 -0
- package/dist/src/Component/dashboard-list/index.js +7 -0
- package/dist/src/Component/project-picker/index.d.ts +15 -0
- package/dist/src/Component/project-picker/index.js +42 -0
- package/dist/src/Component/run-table/index.d.ts +10 -0
- package/dist/src/Component/run-table/index.js +20 -0
- package/dist/src/Component/tab-bar/index.d.ts +20 -0
- package/dist/src/Component/tab-bar/index.js +42 -0
- package/dist/src/Component/ticket-detail/index.d.ts +38 -0
- package/dist/src/Component/ticket-detail/index.js +193 -0
- package/dist/src/Component/ticket-table/index.d.ts +5 -0
- package/dist/src/Component/ticket-table/index.js +14 -0
- package/dist/src/Component/ticket-teaser/index.d.ts +7 -0
- package/dist/src/Component/ticket-teaser/index.js +76 -0
- package/dist/src/Deriver/liveness.d.ts +12 -0
- package/dist/src/Deriver/liveness.js +34 -0
- package/dist/src/Deriver/run-status.d.ts +12 -0
- package/dist/src/Deriver/run-status.js +36 -0
- package/dist/src/Entity/conductor/Component/conductor-list/index.d.ts +25 -0
- package/dist/src/Entity/conductor/Component/conductor-list/index.js +60 -0
- package/dist/src/Entity/project/Component/dashboard-list/index.d.ts +8 -0
- package/dist/src/Entity/project/Component/dashboard-list/index.js +7 -0
- package/dist/src/Entity/project/Component/project-list/index.d.ts +8 -0
- package/dist/src/Entity/project/Component/project-list/index.js +7 -0
- package/dist/src/Entity/project/Component/project-picker/index.d.ts +15 -0
- package/dist/src/Entity/project/Component/project-picker/index.js +42 -0
- package/dist/src/Entity/run/Component/run-table/index.d.ts +8 -0
- package/dist/src/Entity/run/Component/run-table/index.js +11 -0
- package/dist/src/Entity/ticket/Component/tab-bar/index.d.ts +20 -0
- package/dist/src/Entity/ticket/Component/tab-bar/index.js +71 -0
- package/dist/src/Entity/ticket/Component/ticket-table/index.d.ts +7 -0
- package/dist/src/Entity/ticket/Component/ticket-table/index.js +10 -0
- package/dist/src/Entity/user/Screen/users.d.ts +39 -0
- package/dist/src/Entity/user/Screen/users.js +136 -0
- package/dist/src/Screen/dashboard.d.ts +1 -0
- package/dist/src/Screen/dashboard.js +1 -0
- package/dist/src/Screen/project.d.ts +1 -0
- package/dist/src/Screen/project.js +1 -0
- package/dist/src/Screen/projects.d.ts +1 -0
- package/dist/src/Screen/projects.js +1 -0
- package/dist/src/Screen/ticket.d.ts +1 -0
- package/dist/src/Screen/ticket.js +1 -0
- package/dist/src/Service/agent.d.ts +17 -0
- package/dist/src/Service/agent.js +28 -0
- package/dist/src/Service/create-form-data.d.ts +10 -0
- package/dist/src/Service/create-form-data.js +52 -0
- package/dist/src/Service/create-form.d.ts +52 -0
- package/dist/src/Service/create-form.js +98 -0
- package/dist/src/Service/project-data.d.ts +48 -0
- package/dist/src/Service/project-data.js +92 -0
- package/dist/src/Service/projects.d.ts +21 -0
- package/dist/src/Service/projects.js +80 -0
- package/dist/src/Service/route.d.ts +11 -0
- package/dist/src/Service/route.js +215 -0
- package/dist/src/Service/run-data.d.ts +35 -0
- package/dist/src/Service/run-data.js +62 -0
- package/dist/src/Service/ticket-data.d.ts +6 -0
- package/dist/src/Service/ticket-data.js +7 -0
- package/dist/src/Tui/controller.d.ts +13 -0
- package/dist/src/Tui/controller.js +1794 -0
- package/dist/src/lib/ansi.d.ts +34 -0
- package/dist/src/lib/ansi.js +97 -0
- package/dist/src/lib/hit-test.d.ts +138 -0
- package/dist/src/lib/hit-test.js +138 -0
- package/dist/src/lib/mouse.d.ts +35 -0
- package/dist/src/lib/mouse.js +89 -0
- package/dist/src/lib/table.d.ts +37 -0
- package/dist/src/lib/table.js +20 -0
- package/dist/src/lib/ticket-query.d.ts +21 -0
- package/dist/src/lib/ticket-query.js +72 -0
- package/package.json +2 -2
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { HostedAgent } from '../../../types.js';
|
|
2
|
+
/** A fixed-column flat table for the Ticket-Agents screen: header + one line per
|
|
3
|
+
* live agent (its label · the project/group it runs for). One physical row per
|
|
4
|
+
* entry, so the Kernel's `1 + index` selection math holds. The agents are the
|
|
5
|
+
* host's live set (from `list(group)`); a finished agent drops out on refresh. */
|
|
6
|
+
export declare function agentListRows(agents: HostedAgent[]): string[];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { pad } from '../../../lib/format.js';
|
|
2
|
+
/** A fixed-column flat table for the Ticket-Agents screen: header + one line per
|
|
3
|
+
* live agent (its label · the project/group it runs for). One physical row per
|
|
4
|
+
* entry, so the Kernel's `1 + index` selection math holds. The agents are the
|
|
5
|
+
* host's live set (from `list(group)`); a finished agent drops out on refresh. */
|
|
6
|
+
export function agentListRows(agents) {
|
|
7
|
+
const header = `${pad('AGENT', 32)} PROJECT`;
|
|
8
|
+
if (agents.length === 0)
|
|
9
|
+
return [header, 'No ticket agents.'];
|
|
10
|
+
return [
|
|
11
|
+
header,
|
|
12
|
+
...agents.map((a) => `${pad(a.label || a.id, 32)} ${a.group}`),
|
|
13
|
+
];
|
|
14
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { HostedAgent } from '../../types.js';
|
|
2
|
+
/** A fixed-column flat table for the Ticket-Agents screen: header + one line per
|
|
3
|
+
* live agent (its label · the project/group it runs for). One physical row per
|
|
4
|
+
* entry, so the Kernel's `1 + index` selection math holds. The agents are the
|
|
5
|
+
* host's live set (from `list(group)`); a finished agent drops out on refresh. */
|
|
6
|
+
export declare function agentListRows(agents: HostedAgent[]): string[];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { pad } from '../../lib/format.js';
|
|
2
|
+
/** A fixed-column flat table for the Ticket-Agents screen: header + one line per
|
|
3
|
+
* live agent (its label · the project/group it runs for). One physical row per
|
|
4
|
+
* entry, so the Kernel's `1 + index` selection math holds. The agents are the
|
|
5
|
+
* host's live set (from `list(group)`); a finished agent drops out on refresh. */
|
|
6
|
+
export function agentListRows(agents) {
|
|
7
|
+
const header = `${pad('AGENT', 32)} PROJECT`;
|
|
8
|
+
if (agents.length === 0)
|
|
9
|
+
return [header, 'No ticket agents.'];
|
|
10
|
+
return [
|
|
11
|
+
header,
|
|
12
|
+
...agents.map((a) => `${pad(a.label || a.id, 32)} ${a.group}`),
|
|
13
|
+
];
|
|
14
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** One collapsible comment entry for the Comments tab. */
|
|
2
|
+
export interface CommentItem {
|
|
3
|
+
type: string;
|
|
4
|
+
author: string;
|
|
5
|
+
created: string | null;
|
|
6
|
+
bodyLines: string[];
|
|
7
|
+
preview: string;
|
|
8
|
+
}
|
|
9
|
+
/** The rendered Comments-tab view. */
|
|
10
|
+
export interface CommentsTabView {
|
|
11
|
+
lines: string[];
|
|
12
|
+
boldRows: Set<number>;
|
|
13
|
+
headerRows: number[];
|
|
14
|
+
selectable: number;
|
|
15
|
+
}
|
|
16
|
+
/** The relative age of a comment's ISO `created`, or '—' when unknown. */
|
|
17
|
+
export declare function relativeCreated(created: string | null | undefined, nowSeconds: number): string;
|
|
18
|
+
export declare function commentsTabView(items: CommentItem[], { expanded, nowSeconds, }?: {
|
|
19
|
+
expanded?: Set<number>;
|
|
20
|
+
nowSeconds?: number;
|
|
21
|
+
}): CommentsTabView;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { humanizeAge } from '../../Deriver/liveness.js';
|
|
2
|
+
/** The relative age of a comment's ISO `created`, or '—' when unknown. */
|
|
3
|
+
export function relativeCreated(created, nowSeconds) {
|
|
4
|
+
const ms = created ? Date.parse(created) : Number.NaN;
|
|
5
|
+
return Number.isNaN(ms)
|
|
6
|
+
? '—'
|
|
7
|
+
: humanizeAge(Math.floor(ms / 1000), nowSeconds);
|
|
8
|
+
}
|
|
9
|
+
// The Comments tab: one collapsible entry per comment. Collapsed = a single
|
|
10
|
+
// caret header row (`▸ type · author · age · preview`); expanded (`▾`) appends
|
|
11
|
+
// the full body, one physical row per body line. `headerRows[i]` is the row
|
|
12
|
+
// index of comment i's header (for selection highlight + scroll).
|
|
13
|
+
export function commentsTabView(items, { expanded = new Set(), nowSeconds = 0, } = {}) {
|
|
14
|
+
if (!items.length) {
|
|
15
|
+
return {
|
|
16
|
+
lines: ['No comments.'],
|
|
17
|
+
boldRows: new Set(),
|
|
18
|
+
headerRows: [],
|
|
19
|
+
selectable: 0,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const lines = [];
|
|
23
|
+
const boldRows = new Set();
|
|
24
|
+
const headerRows = [];
|
|
25
|
+
items.forEach((item, i) => {
|
|
26
|
+
headerRows.push(lines.length);
|
|
27
|
+
boldRows.add(lines.length);
|
|
28
|
+
const caret = expanded.has(i) ? '▾' : '▸';
|
|
29
|
+
const age = relativeCreated(item.created, nowSeconds);
|
|
30
|
+
const author = item.author || '—';
|
|
31
|
+
lines.push(`${caret} ${item.type} · ${author} · ${age} · ${item.preview}`);
|
|
32
|
+
if (expanded.has(i)) {
|
|
33
|
+
for (const bodyLine of item.bodyLines)
|
|
34
|
+
lines.push(` ${bodyLine}`);
|
|
35
|
+
lines.push('');
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
return { lines, boldRows, headerRows, selectable: items.length };
|
|
39
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const labelOf = (list, id) => list.find((o) => o.id === id)?.label ?? '—';
|
|
2
|
+
/** Render the "New ticket" popup form as plain rows (the kernel frames it). */
|
|
3
|
+
export function createFormLines(s, opts) {
|
|
4
|
+
const mark = (i) => (s.active === i ? '▸ ' : ' ');
|
|
5
|
+
const lines = [];
|
|
6
|
+
lines.push(`${mark(0)}Description:`);
|
|
7
|
+
const body = s.description.length ? s.description.split('\n') : ['<empty>'];
|
|
8
|
+
for (const line of body)
|
|
9
|
+
lines.push(` ${line}`);
|
|
10
|
+
lines.push(`${mark(1)}Assignee: < ${labelOf(opts.users, s.assignee)} >`);
|
|
11
|
+
lines.push(`${mark(2)}Conductor: < ${labelOf(opts.conductors, s.conductor)} >`);
|
|
12
|
+
lines.push(`${mark(3)}Labels:`);
|
|
13
|
+
if (opts.labels.length === 0) {
|
|
14
|
+
lines.push(' (none available)');
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
opts.labels.forEach((o, i) => {
|
|
18
|
+
const box = s.labels.has(o.id) ? '[x]' : '[ ]';
|
|
19
|
+
const cursor = s.active === 3 && s.labelCursor === i ? '›' : ' ';
|
|
20
|
+
lines.push(` ${cursor}${box} ${o.label}`);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
lines.push(`${mark(4)}Workflow: < ${labelOf(opts.workflows, s.workflow)} >`);
|
|
24
|
+
lines.push(`${mark(5)}[ Create ticket ]`);
|
|
25
|
+
return lines;
|
|
26
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The minimum shape a dashboard row needs to render. */
|
|
2
|
+
export interface DashboardRowInput {
|
|
3
|
+
project: string;
|
|
4
|
+
derivedStatus: string;
|
|
5
|
+
ageLabel: string;
|
|
6
|
+
}
|
|
7
|
+
/** One line per project: name, derived status, relative age. */
|
|
8
|
+
export declare function dashboardRows(projects: DashboardRowInput[]): string[];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { pad } from '../../lib/format.js';
|
|
2
|
+
/** One line per project: name, derived status, relative age. */
|
|
3
|
+
export function dashboardRows(projects) {
|
|
4
|
+
if (projects.length === 0)
|
|
5
|
+
return ['No projects.'];
|
|
6
|
+
return projects.map((p) => `${pad(p.project, 24)} ${pad(p.derivedStatus, 14)} ${p.ageLabel}`);
|
|
7
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** A project that claims the repo. */
|
|
2
|
+
export interface PickerCandidate {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The picker screen's rows: a heading naming the repo, then one row per
|
|
8
|
+
* candidate with `▸` on the selected one.
|
|
9
|
+
*/
|
|
10
|
+
export declare function projectPickerRows(candidates: PickerCandidate[], repo: string, selected: number): string[];
|
|
11
|
+
/**
|
|
12
|
+
* The non-TTY form (spec Decision 11): the candidate list plus the direct
|
|
13
|
+
* command, printed once so `gaia ui --here | cat` stays scriptable — no prompt.
|
|
14
|
+
*/
|
|
15
|
+
export declare function projectPickerHint(candidates: PickerCandidate[], repo: string): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// GAIA-223 AC-8 (spec Decision 14) — the picker `gaia ui --here` shows when the
|
|
2
|
+
// cwd's repo is claimed by MORE THAN ONE gaia project.
|
|
3
|
+
//
|
|
4
|
+
// One repo mapping to several projects is designed for, not an edge case:
|
|
5
|
+
// GAIA-126/137 let `.gaia/` hold one conductor config per project, which is how
|
|
6
|
+
// a Drupal multisite repo maps each site to its own GAIA project. Erroring on
|
|
7
|
+
// N>1 would fail permanently, by design, in exactly the repos the feature is
|
|
8
|
+
// most useful in — so the ambiguity is shown rather than resolved behind the
|
|
9
|
+
// user's back.
|
|
10
|
+
//
|
|
11
|
+
// Pure, like every other Component: candidates in, rows out. The kernel owns
|
|
12
|
+
// selection state, Enter and Esc.
|
|
13
|
+
/** Clamp a selection index into the candidate range (0 when there are none). */
|
|
14
|
+
function clamp(selected, length) {
|
|
15
|
+
if (length <= 0)
|
|
16
|
+
return 0;
|
|
17
|
+
return Math.max(0, Math.min(Math.trunc(selected), length - 1));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The picker screen's rows: a heading naming the repo, then one row per
|
|
21
|
+
* candidate with `▸` on the selected one.
|
|
22
|
+
*/
|
|
23
|
+
export function projectPickerRows(candidates, repo, selected) {
|
|
24
|
+
const heading = [`Several gaia projects claim ${repo} — which one?`, ''];
|
|
25
|
+
if (candidates.length === 0) {
|
|
26
|
+
return [...heading, 'No projects.'];
|
|
27
|
+
}
|
|
28
|
+
const index = clamp(selected, candidates.length);
|
|
29
|
+
return [
|
|
30
|
+
...heading,
|
|
31
|
+
...candidates.map((c, i) => `${i === index ? '▸' : ' '} ${c.name}`),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The non-TTY form (spec Decision 11): the candidate list plus the direct
|
|
36
|
+
* command, printed once so `gaia ui --here | cat` stays scriptable — no prompt.
|
|
37
|
+
*/
|
|
38
|
+
export function projectPickerHint(candidates, repo) {
|
|
39
|
+
const names = candidates.map((c) => ` ${c.name}`).join('\n');
|
|
40
|
+
return (`Several gaia projects claim ${repo}:\n${names}\n` +
|
|
41
|
+
"Open one directly with 'gaia ui project:<name>'.");
|
|
42
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RunRow } from '../../Service/run-data.js';
|
|
2
|
+
/**
|
|
3
|
+
* A fixed-column flat table of a project's runs: header + one line per run.
|
|
4
|
+
* Columns RUN · TICKET · STATE · PHASE · AGENT · STARTED, mirroring
|
|
5
|
+
* `ticketTableRows` (1 logical entry = 1 physical row). STATE is the derived
|
|
6
|
+
* liveness status and STARTED is the relative start age.
|
|
7
|
+
*/
|
|
8
|
+
export declare function runTableRows(runs: RunRow[], nowSeconds: number, { emptyMessage }?: {
|
|
9
|
+
emptyMessage?: string | undefined;
|
|
10
|
+
}): string[];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { deriveRunStatus, runStartedAge } from '../../Deriver/run-status.js';
|
|
2
|
+
import { pad } from '../../lib/format.js';
|
|
3
|
+
/**
|
|
4
|
+
* A fixed-column flat table of a project's runs: header + one line per run.
|
|
5
|
+
* Columns RUN · TICKET · STATE · PHASE · AGENT · STARTED, mirroring
|
|
6
|
+
* `ticketTableRows` (1 logical entry = 1 physical row). STATE is the derived
|
|
7
|
+
* liveness status and STARTED is the relative start age.
|
|
8
|
+
*/
|
|
9
|
+
export function runTableRows(runs, nowSeconds, { emptyMessage } = {}) {
|
|
10
|
+
const header = `${pad('RUN', 8)} ${pad('TICKET', 10)} ${pad('STATE', 8)} ${pad('PHASE', 10)} ${pad('AGENT', 8)} STARTED`;
|
|
11
|
+
if (runs.length === 0)
|
|
12
|
+
return [header, emptyMessage ?? 'No runs for this project.'];
|
|
13
|
+
return [
|
|
14
|
+
header,
|
|
15
|
+
...runs.map((r) => {
|
|
16
|
+
const run = `#${r.drupalId ?? r.id.slice(0, 6)}`;
|
|
17
|
+
return `${pad(run, 8)} ${pad(r.ticketIdentifier, 10)} ${pad(deriveRunStatus(r, nowSeconds), 8)} ${pad(r.phase, 10)} ${pad(r.agent, 8)} ${runStartedAge(r, nowSeconds)}`;
|
|
18
|
+
}),
|
|
19
|
+
];
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** One ticket-detail tab: its id, label, and the section headings it groups. */
|
|
2
|
+
export interface DetailTab {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
headings: string[];
|
|
6
|
+
}
|
|
7
|
+
export declare const DETAIL_TABS: DetailTab[];
|
|
8
|
+
/** The tab bar as a single row; the active tab is bracketed. */
|
|
9
|
+
export declare function tabBar(activeIndex: number): string;
|
|
10
|
+
/** Where one detail tab sits inside the bar: `[start, end)` in display columns
|
|
11
|
+
* from the bar's own first column. */
|
|
12
|
+
export interface DetailTabSpan {
|
|
13
|
+
index: number;
|
|
14
|
+
start: number;
|
|
15
|
+
end: number;
|
|
16
|
+
}
|
|
17
|
+
/** The bar's own hit map, built from the SAME `segment`/`SEPARATOR` the rendered
|
|
18
|
+
* bar is joined from — see `Component/top-tabs/index.ts`'s `topTabSpans` for the
|
|
19
|
+
* full argument. */
|
|
20
|
+
export declare function detailTabSpans(activeIndex: number): DetailTabSpan[];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { displayWidth } from '../../lib/width.js';
|
|
2
|
+
// The ticket-detail screen is split into tabs so only one tab's content draws at
|
|
3
|
+
// a time (keeps the fixed-height window small) — Sub-tickets + Referenced share a
|
|
4
|
+
// Related tab, Comments is its own tab with collapsible entries (owned by the
|
|
5
|
+
// plugin's draw loop, which needs the interactive expand state).
|
|
6
|
+
export const DETAIL_TABS = [
|
|
7
|
+
{
|
|
8
|
+
id: 'overview',
|
|
9
|
+
label: 'Overview',
|
|
10
|
+
headings: ['Description', 'Branch', 'Links', 'Environments'],
|
|
11
|
+
},
|
|
12
|
+
{ id: 'comments', label: 'Comments', headings: [] },
|
|
13
|
+
{
|
|
14
|
+
id: 'related',
|
|
15
|
+
label: 'Related',
|
|
16
|
+
headings: ['Sub-tickets', 'Referenced tickets'],
|
|
17
|
+
},
|
|
18
|
+
{ id: 'runs', label: 'Runs', headings: ['Runs'] },
|
|
19
|
+
];
|
|
20
|
+
/** What joins two tabs in the rendered bar — part of neither tab's span. */
|
|
21
|
+
const SEPARATOR = ' ';
|
|
22
|
+
/** One tab's rendered segment: bracketed when active, space-padded otherwise. */
|
|
23
|
+
const segment = (tab, index, activeIndex) => index === activeIndex ? `[${tab.label}]` : ` ${tab.label} `;
|
|
24
|
+
/** The tab bar as a single row; the active tab is bracketed. */
|
|
25
|
+
export function tabBar(activeIndex) {
|
|
26
|
+
return DETAIL_TABS.map((t, i) => segment(t, i, activeIndex)).join(SEPARATOR);
|
|
27
|
+
}
|
|
28
|
+
/** The bar's own hit map, built from the SAME `segment`/`SEPARATOR` the rendered
|
|
29
|
+
* bar is joined from — see `Component/top-tabs/index.ts`'s `topTabSpans` for the
|
|
30
|
+
* full argument. */
|
|
31
|
+
export function detailTabSpans(activeIndex) {
|
|
32
|
+
const spans = [];
|
|
33
|
+
let start = 0;
|
|
34
|
+
DETAIL_TABS.forEach((tab, index) => {
|
|
35
|
+
if (index > 0)
|
|
36
|
+
start += displayWidth(SEPARATOR);
|
|
37
|
+
const width = displayWidth(segment(tab, index, activeIndex));
|
|
38
|
+
spans.push({ index, start, end: start + width });
|
|
39
|
+
start += width;
|
|
40
|
+
});
|
|
41
|
+
return spans;
|
|
42
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { TicketDetailDocument } from '../../types.js';
|
|
2
|
+
import type { CommentItem } from '../comment-item/index.js';
|
|
3
|
+
import type { Section } from '../section-list/index.js';
|
|
4
|
+
/**
|
|
5
|
+
* The `expandedComments` set a freshly opened ticket detail starts with: the
|
|
6
|
+
* newest comment expanded, every older one collapsed.
|
|
7
|
+
*
|
|
8
|
+
* It lives beside the sort on purpose — "index 0 is the newest" is one module's
|
|
9
|
+
* invariant, so the kernel never needs to know which index that is.
|
|
10
|
+
*/
|
|
11
|
+
export declare function initialExpandedComments(document: TicketDetailDocument): Set<number>;
|
|
12
|
+
/**
|
|
13
|
+
* The ticket detail sections, each with an explicit empty state:
|
|
14
|
+
* Description, Comments, Sub-tickets, Runs, Referenced tickets, Links,
|
|
15
|
+
* Branch, Environments.
|
|
16
|
+
*/
|
|
17
|
+
export declare function detailSections(document: TicketDetailDocument): Section[];
|
|
18
|
+
/** The detailSections that belong to a given (non-comments) tab, in tab order. */
|
|
19
|
+
export declare function sectionsForTab(document: TicketDetailDocument, tabIndex: number): Section[];
|
|
20
|
+
/**
|
|
21
|
+
* One item per comment for the collapsible Comments tab: type, author, created
|
|
22
|
+
* (raw ISO — the plugin formats the relative age), the body split into physical
|
|
23
|
+
* lines, and a first-line preview for the collapsed header.
|
|
24
|
+
*/
|
|
25
|
+
export declare function commentItems(document: TicketDetailDocument): CommentItem[];
|
|
26
|
+
export declare function detailLines(detail?: TicketDetailDocument | null): {
|
|
27
|
+
lines: string[];
|
|
28
|
+
boldRows: Set<number>;
|
|
29
|
+
};
|
|
30
|
+
export declare function tabViewLines(detail: TicketDetailDocument | null | undefined, tabIndex: number, { expanded, nowSeconds, }?: {
|
|
31
|
+
expanded?: Set<number>;
|
|
32
|
+
nowSeconds?: number;
|
|
33
|
+
}): {
|
|
34
|
+
lines: string[];
|
|
35
|
+
boldRows: Set<number>;
|
|
36
|
+
headerRows: number[];
|
|
37
|
+
selectable: number;
|
|
38
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// The ticket-detail screen: composes section-list (flatten + markdown bold),
|
|
2
|
+
// tab-bar (DETAIL_TABS) and comment-item (the collapsible Comments tab) into
|
|
3
|
+
// the two shapes the plugin's draw loop consumes — the whole document
|
|
4
|
+
// flattened (`detailLines`, non-TTY/back-compat) and one tab at a time
|
|
5
|
+
// (`tabViewLines`, the interactive screen).
|
|
6
|
+
import { asString } from '../../lib/format.js';
|
|
7
|
+
import { commentsTabView } from '../comment-item/index.js';
|
|
8
|
+
import { flattenSections } from '../section-list/index.js';
|
|
9
|
+
import { DETAIL_TABS } from '../tab-bar/index.js';
|
|
10
|
+
function fieldValue(value) {
|
|
11
|
+
if (value && typeof value === 'object' && 'value' in value) {
|
|
12
|
+
const inner = value.value;
|
|
13
|
+
if (typeof inner === 'string')
|
|
14
|
+
return inner;
|
|
15
|
+
}
|
|
16
|
+
return typeof value === 'string' ? value : '';
|
|
17
|
+
}
|
|
18
|
+
function refCount(document, name) {
|
|
19
|
+
const data = document.data?.relationships?.[name]?.data;
|
|
20
|
+
return Array.isArray(data) ? data.length : data ? 1 : 0;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Newest-first — the exact reversal of the server's total order
|
|
24
|
+
* (`CommentsItemList`: `created ASC, id ASC`), three keys applied in turn:
|
|
25
|
+
* `created` DESC, then `drupal_internal__id` DESC (the server's own monotonic
|
|
26
|
+
* key, so two comments written in the same second keep the server's relative
|
|
27
|
+
* order, reversed), then the resource `id` DESC.
|
|
28
|
+
*
|
|
29
|
+
* The third key exists to make the comparator TOTAL rather than leaning on
|
|
30
|
+
* `Array.prototype.sort` being stable in modern V8: the order must be
|
|
31
|
+
* *deterministic* — identical for the same comments in any input order, and
|
|
32
|
+
* defined even for a fixture or partial include that carries no
|
|
33
|
+
* `drupal_internal__id`. Stability would only preserve whatever order the
|
|
34
|
+
* include happened to arrive in.
|
|
35
|
+
*
|
|
36
|
+
* A missing or unparseable `created` sorts LAST (treated as the oldest), so a
|
|
37
|
+
* malformed row can never take the top slot and steal the default expansion.
|
|
38
|
+
*/
|
|
39
|
+
function compareCommentsNewestFirst(a, b) {
|
|
40
|
+
const at = Date.parse(asString(a.attributes?.created) ?? '');
|
|
41
|
+
const bt = Date.parse(asString(b.attributes?.created) ?? '');
|
|
42
|
+
const aUnknown = Number.isNaN(at);
|
|
43
|
+
const bUnknown = Number.isNaN(bt);
|
|
44
|
+
if (aUnknown !== bUnknown)
|
|
45
|
+
return aUnknown ? 1 : -1;
|
|
46
|
+
if (!aUnknown && at !== bt)
|
|
47
|
+
return bt - at;
|
|
48
|
+
const ai = Number(a.attributes?.drupal_internal__id);
|
|
49
|
+
const bi = Number(b.attributes?.drupal_internal__id);
|
|
50
|
+
if (!Number.isNaN(ai) && !Number.isNaN(bi) && ai !== bi)
|
|
51
|
+
return bi - ai;
|
|
52
|
+
// Code-unit comparison, not localeCompare: a locale-sensitive collation would
|
|
53
|
+
// put the "deterministic" back in the hands of the runtime's locale data.
|
|
54
|
+
const aid = a.id ?? '';
|
|
55
|
+
const bid = b.id ?? '';
|
|
56
|
+
return aid === bid ? 0 : aid < bid ? 1 : -1;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The document's `gaia_comment` includes, NEWEST FIRST.
|
|
60
|
+
*
|
|
61
|
+
* This is the single function both renderings read — `detailSections`' Comments
|
|
62
|
+
* section (→ `detailLines`, the non-TTY render) and `commentItems` (→ the
|
|
63
|
+
* interactive Comments tab) — so newest-first is one line of code and the two
|
|
64
|
+
* orders structurally cannot drift.
|
|
65
|
+
*
|
|
66
|
+
* It sorts the copy `filter` already allocated and NEVER `document.included`
|
|
67
|
+
* itself: that array is mixed (comments *plus* sub-tickets, runs and referenced
|
|
68
|
+
* tickets) and shared with those consumers, so an in-place sort would be an
|
|
69
|
+
* invisible side effect on someone else's input.
|
|
70
|
+
*/
|
|
71
|
+
function commentResources(document) {
|
|
72
|
+
return (document.included ?? [])
|
|
73
|
+
.filter((r) => r.type === 'gaia_comment--gaia_comment')
|
|
74
|
+
.sort(compareCommentsNewestFirst);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The `expandedComments` set a freshly opened ticket detail starts with: the
|
|
78
|
+
* newest comment expanded, every older one collapsed.
|
|
79
|
+
*
|
|
80
|
+
* It lives beside the sort on purpose — "index 0 is the newest" is one module's
|
|
81
|
+
* invariant, so the kernel never needs to know which index that is.
|
|
82
|
+
*/
|
|
83
|
+
export function initialExpandedComments(document) {
|
|
84
|
+
return commentResources(document).length
|
|
85
|
+
? new Set([0])
|
|
86
|
+
: new Set();
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The ticket detail sections, each with an explicit empty state:
|
|
90
|
+
* Description, Comments, Sub-tickets, Runs, Referenced tickets, Links,
|
|
91
|
+
* Branch, Environments.
|
|
92
|
+
*/
|
|
93
|
+
export function detailSections(document) {
|
|
94
|
+
const a = document.data?.attributes ?? {};
|
|
95
|
+
const sections = [];
|
|
96
|
+
const description = fieldValue(a.description);
|
|
97
|
+
sections.push({
|
|
98
|
+
heading: 'Description',
|
|
99
|
+
lines: description ? description.split('\n') : ['No description.'],
|
|
100
|
+
});
|
|
101
|
+
const comments = commentResources(document);
|
|
102
|
+
sections.push({
|
|
103
|
+
heading: 'Comments',
|
|
104
|
+
// One physical line per body line: a `— type ·` marker followed by the body
|
|
105
|
+
// split on '\n' (mirrors the Description section). Keeping a multi-line body
|
|
106
|
+
// in a single entry breaks draw()'s one-entry-one-row invariant (AC-4).
|
|
107
|
+
lines: comments.length
|
|
108
|
+
? comments.flatMap((c) => {
|
|
109
|
+
const type = asString(c.attributes?.gaia_comment_type) ?? 'comment';
|
|
110
|
+
const body = fieldValue(c.attributes?.body);
|
|
111
|
+
return [`— ${type} ·`, ...(body ? body.split('\n') : [''])];
|
|
112
|
+
})
|
|
113
|
+
: ['No comments.'],
|
|
114
|
+
});
|
|
115
|
+
const relSection = (heading, rel) => {
|
|
116
|
+
const n = refCount(document, rel);
|
|
117
|
+
sections.push({
|
|
118
|
+
heading,
|
|
119
|
+
lines: n
|
|
120
|
+
? [`${n} ${heading.toLowerCase()}`]
|
|
121
|
+
: [`No ${heading.toLowerCase()}.`],
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
relSection('Sub-tickets', 'sub_tickets');
|
|
125
|
+
relSection('Runs', 'runs');
|
|
126
|
+
relSection('Referenced tickets', 'referenced_tickets');
|
|
127
|
+
const links = Array.isArray(a.links) ? a.links : [];
|
|
128
|
+
sections.push({
|
|
129
|
+
heading: 'Links',
|
|
130
|
+
lines: links.length
|
|
131
|
+
? links.map((l) => {
|
|
132
|
+
const link = l;
|
|
133
|
+
return `${link.title ?? ''} ${link.uri ?? link.url ?? ''}`.trim();
|
|
134
|
+
})
|
|
135
|
+
: ['No links.'],
|
|
136
|
+
});
|
|
137
|
+
sections.push({
|
|
138
|
+
heading: 'Branch',
|
|
139
|
+
lines: [
|
|
140
|
+
`branch: ${asString(a.branch_name) ?? '—'}`,
|
|
141
|
+
`base: ${asString(a.base_branch) ?? '—'}`,
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
relSection('Environments', 'environments');
|
|
145
|
+
return sections;
|
|
146
|
+
}
|
|
147
|
+
/** The detailSections that belong to a given (non-comments) tab, in tab order. */
|
|
148
|
+
export function sectionsForTab(document, tabIndex) {
|
|
149
|
+
const tab = DETAIL_TABS[tabIndex];
|
|
150
|
+
const headings = tab?.headings ?? [];
|
|
151
|
+
if (!headings.length)
|
|
152
|
+
return [];
|
|
153
|
+
const byHeading = new Map(detailSections(document).map((s) => [s.heading, s]));
|
|
154
|
+
return headings
|
|
155
|
+
.map((h) => byHeading.get(h))
|
|
156
|
+
.filter((s) => s !== undefined);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* One item per comment for the collapsible Comments tab: type, author, created
|
|
160
|
+
* (raw ISO — the plugin formats the relative age), the body split into physical
|
|
161
|
+
* lines, and a first-line preview for the collapsed header.
|
|
162
|
+
*/
|
|
163
|
+
export function commentItems(document) {
|
|
164
|
+
const comments = commentResources(document);
|
|
165
|
+
return comments.map((c) => {
|
|
166
|
+
const type = asString(c.attributes?.gaia_comment_type) ?? 'comment';
|
|
167
|
+
const author = asString(c.attributes?.author_name) ?? '';
|
|
168
|
+
const created = asString(c.attributes?.created) ?? null;
|
|
169
|
+
const body = fieldValue(c.attributes?.body);
|
|
170
|
+
const bodyLines = body ? body.split('\n') : [''];
|
|
171
|
+
return { type, author, created, bodyLines, preview: bodyLines[0] ?? '' };
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
// The whole ticket-detail document flattened (all sections) — the NON-TTY ticket
|
|
175
|
+
// render, printed verbatim by the kernel when stdout is not a terminal
|
|
176
|
+
// (`Kernel/tui-kernel.ts:147`), i.e. what `gaia ui GAIA-nnn | cat` shows. The
|
|
177
|
+
// interactive screen renders one tab at a time via tabViewLines instead.
|
|
178
|
+
export function detailLines(detail) {
|
|
179
|
+
return flattenSections(detailSections(detail ?? {}));
|
|
180
|
+
}
|
|
181
|
+
// The rows for one detail tab. The Comments tab is the collapsible list; every
|
|
182
|
+
// other tab flattens just its own sections. Returns `selectable`/`headerRows`
|
|
183
|
+
// so the draw loop can highlight + scroll the selected comment (0 elsewhere).
|
|
184
|
+
export function tabViewLines(detail, tabIndex, { expanded = new Set(), nowSeconds = 0, } = {}) {
|
|
185
|
+
if (DETAIL_TABS[tabIndex]?.id === 'comments') {
|
|
186
|
+
return commentsTabView(commentItems(detail ?? {}), {
|
|
187
|
+
expanded,
|
|
188
|
+
nowSeconds,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
const { lines, boldRows } = flattenSections(sectionsForTab(detail ?? {}, tabIndex));
|
|
192
|
+
return { lines, boldRows, headerRows: [], selectable: 0 };
|
|
193
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { pad } from '../../lib/format.js';
|
|
2
|
+
/** A fixed-column flat table: header + one line per ticket. */
|
|
3
|
+
export function ticketTableRows(tickets, { emptyMessage } = {}) {
|
|
4
|
+
const header = `${pad('IDENTIFIER', 10)} ${pad('STATE', 8)} ${pad('PRIO', 5)} ${pad('WHO', 4)} ${pad('RUN', 8)} ${pad('PARENT', 10)} ${pad('COND', 14)} TITLE`;
|
|
5
|
+
if (tickets.length === 0)
|
|
6
|
+
return [header, emptyMessage ?? 'No tickets.'];
|
|
7
|
+
return [
|
|
8
|
+
header,
|
|
9
|
+
...tickets.map((t) => {
|
|
10
|
+
const labels = t.labels?.length ? ` [${t.labels.join(',')}]` : '';
|
|
11
|
+
return `${pad(t.identifier, 10)} ${pad(t.state, 8)} ${pad(t.priority ?? '—', 5)} ${pad(t.assigneeInitials || '—', 4)} ${pad(t.derivedStatus, 8)} ${pad(t.parent ?? '—', 10)} ${pad(t.conductor ?? '—', 14)} ${t.title}${labels}`;
|
|
12
|
+
}),
|
|
13
|
+
];
|
|
14
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { TicketDetailDocument, UiViewMode } from '../../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* A ticket rendered compactly, with the field set of its Drupal view-mode
|
|
4
|
+
* display. `full` never reaches here — the kernel renders the tabbed detail for
|
|
5
|
+
* it — but it is accepted and treated as `teaser` so the function is total.
|
|
6
|
+
*/
|
|
7
|
+
export declare function ticketTeaserLines(document: TicketDetailDocument, mode: UiViewMode): string[];
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// GAIA-223 AC-7 — the compact ticket block behind `gaia ui <ticket> --view
|
|
2
|
+
// teaser|board_card` (spec Decision 13, variant A).
|
|
3
|
+
//
|
|
4
|
+
// THE FIELD SETS BELOW ARE A DELIBERATE DUPLICATE of two Drupal displays:
|
|
5
|
+
// config/sync/core.entity_view_display.gaia_ticket.gaia_ticket.teaser.yml
|
|
6
|
+
// → gaia_theme:ticket-row — identifier, title, state, derived_status,
|
|
7
|
+
// since_label, claim_machine, assignee_{name,initials,color}
|
|
8
|
+
// config/sync/core.entity_view_display.gaia_ticket.gaia_ticket.board_card.yml
|
|
9
|
+
// → gaia_theme:board-card — identifier, title, state, workflow(type),
|
|
10
|
+
// derived_status, priority, url; footer slot: claim_machine
|
|
11
|
+
//
|
|
12
|
+
// Why duplicated: those displays are display_builder→SDC bindings producing
|
|
13
|
+
// HTML, JSON:API never serves rendered output, and NEITHER the `session` nor the
|
|
14
|
+
// `pm` scope may read `entity_view_display` (both come back empty with
|
|
15
|
+
// "insufficient authorization"). Reading the mapping at runtime — variant B — is
|
|
16
|
+
// the named follow-up in the spec's *Out of scope*; it needs a `gaia_session`
|
|
17
|
+
// read permission in `config/sync` AND the recipe copy, plus proof that
|
|
18
|
+
// `third_party_settings` survives JSON:API serialization. When that lands, this
|
|
19
|
+
// module is the ONE place that changes; the `--view` CLI surface is identical
|
|
20
|
+
// either way. Until then, this file drifting from those YAMLs is the accepted
|
|
21
|
+
// cost, named in Decision 13.
|
|
22
|
+
import { asString } from '../../lib/format.js';
|
|
23
|
+
/** The same em-dash placeholder ticket-table/run-table use for a missing value. */
|
|
24
|
+
const MISSING = '—';
|
|
25
|
+
/** Left column width, so the labels line up like the other components' columns. */
|
|
26
|
+
const LABEL_WIDTH = 10;
|
|
27
|
+
function row(label, value) {
|
|
28
|
+
return `${label.padEnd(LABEL_WIDTH)} ${value}`;
|
|
29
|
+
}
|
|
30
|
+
/** An attribute as a display string, or the shared placeholder. */
|
|
31
|
+
function attr(attributes, key) {
|
|
32
|
+
const raw = attributes[key];
|
|
33
|
+
if (typeof raw === 'string' && raw !== '')
|
|
34
|
+
return raw;
|
|
35
|
+
if (typeof raw === 'number')
|
|
36
|
+
return String(raw);
|
|
37
|
+
return MISSING;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A ticket rendered compactly, with the field set of its Drupal view-mode
|
|
41
|
+
* display. `full` never reaches here — the kernel renders the tabbed detail for
|
|
42
|
+
* it — but it is accepted and treated as `teaser` so the function is total.
|
|
43
|
+
*/
|
|
44
|
+
export function ticketTeaserLines(document, mode) {
|
|
45
|
+
const a = document.data?.attributes ?? {};
|
|
46
|
+
const heading = `${attr(a, 'identifier')} · ${attr(a, 'title')}`;
|
|
47
|
+
if (mode === 'board_card') {
|
|
48
|
+
return [
|
|
49
|
+
heading,
|
|
50
|
+
'',
|
|
51
|
+
row('state', attr(a, 'state')),
|
|
52
|
+
row('type', attr(a, 'workflow')),
|
|
53
|
+
row('status', attr(a, 'derived_status')),
|
|
54
|
+
row('priority', attr(a, 'priority')),
|
|
55
|
+
row('claim', attr(a, 'claim_machine')),
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
// `teaser`. The display binds assignee_name + assignee_initials + a colour;
|
|
59
|
+
// the terminal block shows the name with the initials as its short handle.
|
|
60
|
+
const name = attr(a, 'assignee_name');
|
|
61
|
+
const initials = asString(a.assignee_initials);
|
|
62
|
+
const assignee = name === MISSING
|
|
63
|
+
? (initials ?? MISSING)
|
|
64
|
+
: initials
|
|
65
|
+
? `${name} (${initials})`
|
|
66
|
+
: name;
|
|
67
|
+
return [
|
|
68
|
+
heading,
|
|
69
|
+
'',
|
|
70
|
+
row('state', attr(a, 'state')),
|
|
71
|
+
row('status', attr(a, 'derived_status')),
|
|
72
|
+
row('since', attr(a, 'since_label')),
|
|
73
|
+
row('assignee', assignee),
|
|
74
|
+
row('claim', attr(a, 'claim_machine')),
|
|
75
|
+
];
|
|
76
|
+
}
|