plc-cli 0.2.0 → 0.3.0

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.
Files changed (2) hide show
  1. package/dist/cli.js +596 -68
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { Command } from "commander";
6
6
  // src/meta.ts
7
7
  var PRODUCT_NAME = "Plane Cockpit";
8
8
  var BINARY_NAME = "plc";
9
- var VERSION = true ? "0.2.0" : "0.0.0-dev";
9
+ var VERSION = true ? "0.3.0" : "0.0.0-dev";
10
10
  var AUTHOR_HANDLE = "@brunoomariano";
11
11
 
12
12
  // src/commands/auth/index.ts
@@ -258,6 +258,7 @@ var planeConfigSchema = z.strictObject({
258
258
  var DEFAULT_TIMEOUT_MS = 3e4;
259
259
  var DEFAULT_CACHE_TTL_SECONDS = 300;
260
260
  var STATES_LABELS_TTL_SECONDS = 300;
261
+ var ACTIVITIES_TTL_SECONDS = 60;
261
262
  var DEFAULT_CONFIG_PATHS = ["~/.config/plane-cli/config.yaml"];
262
263
 
263
264
  // src/config/load-config.ts
@@ -986,7 +987,11 @@ var cacheKeys = {
986
987
  states: (slug, projectId) => workspaceKey(slug, "project", projectId, "states"),
987
988
  // Labels are project-scoped too, cached per project id.
988
989
  labels: (slug, projectId) => workspaceKey(slug, "project", projectId, "labels"),
989
- issuesPage: (slug, projectId, hash) => workspaceKey(slug, "project", projectId, "issues", hash)
990
+ issuesPage: (slug, projectId, hash) => workspaceKey(slug, "project", projectId, "issues", hash),
991
+ // The activity log is per work item, so it is cached per project + issue id.
992
+ issueActivities: (slug, projectId, issueId) => workspaceKey(slug, "project", projectId, "issue", issueId, "activities"),
993
+ // Relations are per work item too, cached per project + issue id.
994
+ issueRelations: (slug, projectId, issueId) => workspaceKey(slug, "project", projectId, "issue", issueId, "relations")
990
995
  };
991
996
 
992
997
  // src/plane/projects.ts
@@ -1628,6 +1633,104 @@ var LabelsService = class {
1628
1633
  }
1629
1634
  };
1630
1635
 
1636
+ // src/plane/activities.ts
1637
+ function toActivity(raw) {
1638
+ return {
1639
+ id: raw.id,
1640
+ verb: raw.verb,
1641
+ field: raw.field ?? void 0,
1642
+ oldValue: raw.old_value ?? void 0,
1643
+ newValue: raw.new_value ?? void 0,
1644
+ oldIdentifier: raw.old_identifier ?? void 0,
1645
+ newIdentifier: raw.new_identifier ?? void 0,
1646
+ createdAt: raw.created_at,
1647
+ actor: raw.actor ?? void 0
1648
+ };
1649
+ }
1650
+ function unwrap(res) {
1651
+ if (Array.isArray(res)) return res;
1652
+ if ("result" in res && Array.isArray(res.result)) return res.result;
1653
+ if ("results" in res && Array.isArray(res.results)) return res.results;
1654
+ return [];
1655
+ }
1656
+ var ActivitiesService = class {
1657
+ constructor(api, cache) {
1658
+ this.api = api;
1659
+ this.cache = cache;
1660
+ }
1661
+ api;
1662
+ cache;
1663
+ // list returns the issue's activity log, cache-first. The cached copy lets the
1664
+ // detail view's timing line and the activity tab share a single fetch; the
1665
+ // short TTL means a fresh change shows up the next time the detail opens.
1666
+ async list(project, issueId, signal) {
1667
+ const key = cacheKeys.issueActivities(this.api.workspace, project.id, issueId);
1668
+ const cached2 = await this.cache.get(key);
1669
+ if (cached2) return cached2;
1670
+ const res = await this.api.request(
1671
+ this.api.workspacePath("projects", project.id, "issues", issueId, "activities"),
1672
+ { signal }
1673
+ );
1674
+ const activities = unwrap(res).map(toActivity);
1675
+ await this.cache.set(key, activities, ACTIVITIES_TTL_SECONDS);
1676
+ return activities;
1677
+ }
1678
+ };
1679
+
1680
+ // src/types/relation.ts
1681
+ var RELATION_TYPES = [
1682
+ "blocking",
1683
+ "blocked_by",
1684
+ "relates_to",
1685
+ "duplicate",
1686
+ "start_after",
1687
+ "start_before",
1688
+ "finish_after",
1689
+ "finish_before"
1690
+ ];
1691
+ var RELATION_LABELS = {
1692
+ blocking: "blocking",
1693
+ blocked_by: "blocked by",
1694
+ relates_to: "relates to",
1695
+ duplicate: "duplicate of",
1696
+ start_after: "starts after",
1697
+ start_before: "starts before",
1698
+ finish_after: "finishes after",
1699
+ finish_before: "finishes before"
1700
+ };
1701
+
1702
+ // src/plane/relations.ts
1703
+ function normalize(raw) {
1704
+ const relations = {};
1705
+ for (const type of RELATION_TYPES) {
1706
+ relations[type] = raw[type] ?? [];
1707
+ }
1708
+ return relations;
1709
+ }
1710
+ var RelationsService = class {
1711
+ constructor(api, cache) {
1712
+ this.api = api;
1713
+ this.cache = cache;
1714
+ }
1715
+ api;
1716
+ cache;
1717
+ // list returns the current relations for an issue, cache-first. The short TTL
1718
+ // matches the activity log's: opening the detail and toggling the relations
1719
+ // section reuses one fetch, and a freshly added relation appears on reopen.
1720
+ async list(project, issueId, signal) {
1721
+ const key = cacheKeys.issueRelations(this.api.workspace, project.id, issueId);
1722
+ const cached2 = await this.cache.get(key);
1723
+ if (cached2) return cached2;
1724
+ const raw = await this.api.request(
1725
+ this.api.workspacePath("projects", project.id, "work-items", issueId, "relations"),
1726
+ { signal }
1727
+ );
1728
+ const relations = normalize(raw);
1729
+ await this.cache.set(key, relations, ACTIVITIES_TTL_SECONDS);
1730
+ return relations;
1731
+ }
1732
+ };
1733
+
1631
1734
  // src/keybindings/load.ts
1632
1735
  import { readFile as readFile3 } from "fs/promises";
1633
1736
  import YAML3 from "yaml";
@@ -1749,6 +1852,24 @@ var ACTIONS = [
1749
1852
  },
1750
1853
  { id: "detail.comment", context: "detail", description: "comment on issue", defaultKey: "c" },
1751
1854
  { id: "detail.edit", context: "detail", description: "edit issue fields", defaultKey: "e" },
1855
+ {
1856
+ id: "detail.activity",
1857
+ context: "detail",
1858
+ description: "toggle the state-change activity log",
1859
+ defaultKey: "a"
1860
+ },
1861
+ {
1862
+ id: "detail.relations",
1863
+ context: "detail",
1864
+ description: "toggle the relations section",
1865
+ defaultKey: "l"
1866
+ },
1867
+ {
1868
+ id: "detail.relation-open",
1869
+ context: "detail",
1870
+ description: "open the focused relation",
1871
+ defaultKey: "enter"
1872
+ },
1752
1873
  {
1753
1874
  id: "detail.close",
1754
1875
  context: "detail",
@@ -2043,6 +2164,8 @@ async function buildContext(flags) {
2043
2164
  const users = new UsersService(api, cache);
2044
2165
  const states = new StatesService(api, cache);
2045
2166
  const labels = new LabelsService(api, cache);
2167
+ const activities = new ActivitiesService(api, cache);
2168
+ const relations = new RelationsService(api, cache);
2046
2169
  const issues = new IssuesService(projects, workItems, users);
2047
2170
  const { bindings: keybindings, sourcePath: keybindingsSourcePath } = await loadKeybindings();
2048
2171
  const runtime = {
@@ -2062,6 +2185,8 @@ async function buildContext(flags) {
2062
2185
  users,
2063
2186
  states,
2064
2187
  labels,
2188
+ activities,
2189
+ relations,
2065
2190
  keybindings,
2066
2191
  keybindingsSourcePath,
2067
2192
  theme: resolveTheme(profile.theme),
@@ -2664,7 +2789,7 @@ import React9 from "react";
2664
2789
  import { render } from "ink";
2665
2790
 
2666
2791
  // src/tui/dashboard.tsx
2667
- import React7, { useCallback as useCallback6, useEffect as useEffect5, useMemo as useMemo6, useState as useState11 } from "react";
2792
+ import React7, { useCallback as useCallback7, useEffect as useEffect7, useMemo as useMemo8, useState as useState14 } from "react";
2668
2793
  import { Box as Box12, Text as Text12, useApp, useInput as useInput2 } from "ink";
2669
2794
 
2670
2795
  // src/tui/theme/context.tsx
@@ -3058,7 +3183,7 @@ function IssueList(props) {
3058
3183
  }
3059
3184
 
3060
3185
  // src/tui/issue-detail.tsx
3061
- import { useMemo } from "react";
3186
+ import React5, { useMemo } from "react";
3062
3187
  import { Box as Box5, Text as Text5 } from "ink";
3063
3188
 
3064
3189
  // src/utils/markdown-to-ansi.ts
@@ -3215,18 +3340,67 @@ function splitAnsiIntoLines(text, width) {
3215
3340
  return rows;
3216
3341
  }
3217
3342
 
3343
+ // src/utils/format-duration.ts
3344
+ var MINUTE_MS = 6e4;
3345
+ var HOUR_MS = 60 * MINUTE_MS;
3346
+ var DAY_MS = 24 * HOUR_MS;
3347
+ function humanizeDuration(ms) {
3348
+ if (ms < MINUTE_MS) return "just now";
3349
+ const days = Math.floor(ms / DAY_MS);
3350
+ const hours = Math.floor(ms % DAY_MS / HOUR_MS);
3351
+ const minutes = Math.floor(ms % HOUR_MS / MINUTE_MS);
3352
+ const parts = [];
3353
+ if (days > 0) parts.push(`${days}d`);
3354
+ if (hours > 0) parts.push(`${hours}h`);
3355
+ if (minutes > 0 && days === 0) parts.push(`${minutes}m`);
3356
+ return parts.slice(0, 2).join(" ");
3357
+ }
3358
+
3359
+ // src/tui/format-relation.ts
3360
+ function formatRelationRow(relation, now) {
3361
+ const head = relation.targetKey ?? `${relation.targetId.slice(0, 8)}\u2026`;
3362
+ const parts = [head];
3363
+ if (relation.target) {
3364
+ parts.push(relation.target.state.name);
3365
+ if (relation.target.name) parts.push(relation.target.name);
3366
+ }
3367
+ if (relation.relatedAt) {
3368
+ const when = Date.parse(relation.relatedAt);
3369
+ if (!Number.isNaN(when)) {
3370
+ const elapsed = humanizeDuration(now - when);
3371
+ parts.push(elapsed === "just now" ? "just now" : `${elapsed} ago`);
3372
+ }
3373
+ }
3374
+ return parts.join(" \xB7 ");
3375
+ }
3376
+
3218
3377
  // src/tui/issue-detail.tsx
3219
3378
  import { Fragment as Fragment2, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
3379
+ function formatStateChange(activity, now) {
3380
+ const when = Date.parse(activity.createdAt);
3381
+ const from = activity.oldValue ? `${activity.oldValue} \u2192 ` : "\u2192 ";
3382
+ const head = `${from}${activity.newValue ?? "?"}`;
3383
+ if (Number.isNaN(when)) return head;
3384
+ const elapsed = humanizeDuration(now - when);
3385
+ return elapsed === "just now" ? `${head} \xB7 just now` : `${head} \xB7 ${elapsed} ago`;
3386
+ }
3220
3387
  var MODAL_WIDTH = 100;
3221
3388
  var PANEL_WIDTH = 50;
3222
3389
  var HORIZONTAL_CHROME = 4;
3223
3390
  var DETAIL_CHROME_ROWS = 16;
3224
- function IssueMeta({ issue }) {
3391
+ function IssueMeta({
3392
+ issue,
3393
+ timeInState
3394
+ }) {
3225
3395
  const theme = useTheme();
3226
3396
  return /* @__PURE__ */ jsxs5(Box5, { marginTop: 1, flexDirection: "column", flexShrink: 0, children: [
3227
3397
  /* @__PURE__ */ jsxs5(Text5, { children: [
3228
3398
  "state: ",
3229
- /* @__PURE__ */ jsx6(Text5, { color: theme.accent, children: issue.state.name })
3399
+ /* @__PURE__ */ jsx6(Text5, { color: theme.accent, children: issue.state.name }),
3400
+ timeInState ? /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
3401
+ " \xB7 for ",
3402
+ timeInState
3403
+ ] }) : null
3230
3404
  ] }),
3231
3405
  /* @__PURE__ */ jsxs5(Text5, { children: [
3232
3406
  "priority: ",
@@ -3246,12 +3420,12 @@ function IssueMeta({ issue }) {
3246
3420
  ] })
3247
3421
  ] });
3248
3422
  }
3249
- function DescriptionBody(props) {
3423
+ function ScrollableBody(props) {
3250
3424
  let content;
3251
- if (props.loading && !props.hasDescription) {
3252
- content = /* @__PURE__ */ jsx6(Text5, { dimColor: true, children: "loading description\u2026" });
3425
+ if (props.loading && props.visible.length === 0) {
3426
+ content = /* @__PURE__ */ jsx6(Text5, { dimColor: true, children: props.loadingLabel });
3253
3427
  } else if (props.visible.length === 0 && props.hiddenAbove === 0 && props.hiddenBelow === 0) {
3254
- content = /* @__PURE__ */ jsx6(Text5, { dimColor: true, children: "(no description)" });
3428
+ content = /* @__PURE__ */ jsx6(Text5, { dimColor: true, children: props.emptyLabel });
3255
3429
  } else {
3256
3430
  content = /* @__PURE__ */ jsxs5(Fragment2, { children: [
3257
3431
  props.hiddenAbove > 0 ? /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
@@ -3269,60 +3443,173 @@ function DescriptionBody(props) {
3269
3443
  }
3270
3444
  return /* @__PURE__ */ jsx6(Box5, { marginTop: 1, flexDirection: "column", overflow: "hidden", children: content });
3271
3445
  }
3272
- function closeHintFor(scrollTop, viewportRows, total) {
3273
- if (total <= viewportRows) return "esc to close";
3446
+ function RelationRow({
3447
+ relation,
3448
+ focused,
3449
+ now
3450
+ }) {
3451
+ const theme = useTheme();
3452
+ return /* @__PURE__ */ jsxs5(Text5, { wrap: "truncate", color: focused ? theme.accent : void 0, bold: focused, children: [
3453
+ focused ? "\u203A " : " ",
3454
+ formatRelationRow(relation, now)
3455
+ ] });
3456
+ }
3457
+ function RelationsBody(props) {
3458
+ if (props.relations.length === 0) {
3459
+ return /* @__PURE__ */ jsx6(Box5, { marginTop: 1, flexDirection: "column", overflow: "hidden", children: /* @__PURE__ */ jsx6(Text5, { dimColor: true, children: props.loading ? "loading relations\u2026" : "(no relations)" }) });
3460
+ }
3461
+ const now = Date.now();
3462
+ const start = Math.max(
3463
+ 0,
3464
+ Math.min(props.selected - 1, props.relations.length - props.viewportRows)
3465
+ );
3466
+ const window = props.relations.slice(Math.max(0, start), Math.max(0, start) + props.viewportRows);
3467
+ let lastType;
3468
+ return /* @__PURE__ */ jsx6(Box5, { marginTop: 1, flexDirection: "column", overflow: "hidden", children: window.map((relation, idx) => {
3469
+ const flatIdx = Math.max(0, start) + idx;
3470
+ const heading = relation.type !== lastType ? RELATION_LABELS[relation.type] : void 0;
3471
+ lastType = relation.type;
3472
+ return /* @__PURE__ */ jsxs5(React5.Fragment, { children: [
3473
+ heading ? /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
3474
+ heading,
3475
+ ":"
3476
+ ] }) : null,
3477
+ /* @__PURE__ */ jsx6(RelationRow, { relation, focused: flatIdx === props.selected, now })
3478
+ ] }, `${relation.type}-${relation.targetId}`);
3479
+ }) });
3480
+ }
3481
+ var HINT_BY_MODE = {
3482
+ detail: "esc to close \xB7 a: activity \xB7 l: relations",
3483
+ activity: "esc to close \xB7 a: description",
3484
+ relations: "esc back \xB7 enter: open \xB7 l: description"
3485
+ };
3486
+ function closeHintFor(scrollTop, viewportRows, total, mode) {
3487
+ const label = HINT_BY_MODE[mode];
3488
+ if (mode === "relations" || total <= viewportRows) return label;
3274
3489
  const end = Math.min(scrollTop + viewportRows, total);
3275
- return `esc to close \xB7 ${scrollTop + 1}-${end}/${total}`;
3490
+ return `${label} \xB7 ${scrollTop + 1}-${end}/${total}`;
3491
+ }
3492
+ function DetailHeader(props) {
3493
+ return /* @__PURE__ */ jsxs5(Box5, { justifyContent: "space-between", flexShrink: 0, children: [
3494
+ /* @__PURE__ */ jsxs5(Text5, { bold: true, children: [
3495
+ props.issueKey,
3496
+ props.mode !== "detail" ? /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
3497
+ " \xB7 ",
3498
+ props.mode
3499
+ ] }) : null
3500
+ ] }),
3501
+ props.variant === "modal" ? /* @__PURE__ */ jsx6(Text5, { dimColor: true, children: closeHintFor(props.scrollTop, props.viewportRows, props.total, props.mode) }) : null
3502
+ ] });
3503
+ }
3504
+ var BODY_LABELS = {
3505
+ detail: { loadingLabel: "loading description\u2026", emptyLabel: "(no description)" },
3506
+ activity: { loadingLabel: "loading activity\u2026", emptyLabel: "(no state changes)" }
3507
+ };
3508
+ function resolveBody(mode, lines, props) {
3509
+ const viewportRows = props.viewportRows ?? lines.length;
3510
+ const scrollTop = Math.max(
3511
+ 0,
3512
+ Math.min(props.scrollTop ?? 0, Math.max(0, lines.length - viewportRows))
3513
+ );
3514
+ return {
3515
+ lines,
3516
+ visible: lines.slice(scrollTop, scrollTop + viewportRows),
3517
+ scrollTop,
3518
+ hiddenBelow: Math.max(0, lines.length - scrollTop - viewportRows),
3519
+ loading: (mode === "activity" ? props.activityLoading : props.loading) ?? false,
3520
+ ...BODY_LABELS[mode]
3521
+ };
3522
+ }
3523
+ function frameStyle(variant, accent) {
3524
+ return variant === "modal" ? { borderStyle: "double", borderColor: accent, paddingY: 1 } : { borderStyle: "round", borderColor: void 0, paddingY: 0 };
3525
+ }
3526
+ function DetailBody(props) {
3527
+ if (props.mode === "relations") {
3528
+ return /* @__PURE__ */ jsx6(
3529
+ RelationsBody,
3530
+ {
3531
+ relations: props.relations,
3532
+ selected: props.relationsSelected,
3533
+ loading: props.relationsLoading,
3534
+ viewportRows: props.relationsViewport
3535
+ }
3536
+ );
3537
+ }
3538
+ const b = props.textBody;
3539
+ return /* @__PURE__ */ jsx6(
3540
+ ScrollableBody,
3541
+ {
3542
+ loading: b.loading,
3543
+ loadingLabel: b.loadingLabel,
3544
+ emptyLabel: b.emptyLabel,
3545
+ visible: b.visible,
3546
+ hiddenAbove: b.scrollTop,
3547
+ hiddenBelow: b.hiddenBelow,
3548
+ scrollTop: b.scrollTop
3549
+ }
3550
+ );
3276
3551
  }
3277
3552
  function IssueDetail(props) {
3278
3553
  const theme = useTheme();
3279
3554
  const variant = props.variant ?? "panel";
3555
+ const mode = props.mode ?? "detail";
3280
3556
  const width = variant === "modal" ? MODAL_WIDTH : PANEL_WIDTH;
3281
3557
  const contentWidth = width - HORIZONTAL_CHROME;
3282
3558
  const description = props.issue?.description;
3283
- const rendered = useMemo(() => description ? markdownToAnsi(description) : "", [description]);
3284
- const lines = useMemo(
3285
- () => rendered ? splitAnsiIntoLines(rendered, contentWidth) : [],
3286
- [rendered, contentWidth]
3559
+ const stateChanges = props.stateChanges;
3560
+ const descriptionLines = useMemo(
3561
+ () => description ? splitAnsiIntoLines(markdownToAnsi(description), contentWidth) : [],
3562
+ [description, contentWidth]
3287
3563
  );
3564
+ const activityLines = useMemo(() => {
3565
+ const now = Date.now();
3566
+ return (stateChanges ?? []).map((a) => formatStateChange(a, now)).reverse();
3567
+ }, [stateChanges]);
3288
3568
  if (!props.issue) {
3289
3569
  return /* @__PURE__ */ jsx6(Box5, { borderStyle: "round", paddingX: 1, width, children: /* @__PURE__ */ jsx6(Text5, { dimColor: true, children: "select an issue" }) });
3290
3570
  }
3291
3571
  const i = props.issue;
3292
- const viewportRows = props.viewportRows ?? lines.length;
3293
- const scrollTop = Math.max(
3294
- 0,
3295
- Math.min(props.scrollTop ?? 0, Math.max(0, lines.length - viewportRows))
3572
+ const isActivity = mode === "activity";
3573
+ const textBody = resolveBody(
3574
+ isActivity ? "activity" : "detail",
3575
+ isActivity ? activityLines : descriptionLines,
3576
+ props
3296
3577
  );
3297
- const visible = lines.slice(scrollTop, scrollTop + viewportRows);
3578
+ const viewportRows = props.viewportRows ?? textBody.lines.length;
3579
+ const relationsViewport = Math.max(1, viewportRows);
3298
3580
  return /* @__PURE__ */ jsxs5(
3299
3581
  Box5,
3300
3582
  {
3301
3583
  flexDirection: "column",
3302
- borderStyle: variant === "modal" ? "double" : "round",
3303
- borderColor: variant === "modal" ? theme.accent : void 0,
3584
+ ...frameStyle(variant, theme.accent),
3304
3585
  paddingX: 1,
3305
- paddingY: variant === "modal" ? 1 : 0,
3306
3586
  width,
3307
3587
  height: props.height,
3308
3588
  flexShrink: 0,
3309
3589
  overflow: "hidden",
3310
3590
  children: [
3311
- /* @__PURE__ */ jsxs5(Box5, { justifyContent: "space-between", flexShrink: 0, children: [
3312
- /* @__PURE__ */ jsx6(Text5, { bold: true, children: i.key }),
3313
- variant === "modal" ? /* @__PURE__ */ jsx6(Text5, { dimColor: true, children: closeHintFor(scrollTop, viewportRows, lines.length) }) : null
3314
- ] }),
3591
+ /* @__PURE__ */ jsx6(
3592
+ DetailHeader,
3593
+ {
3594
+ issueKey: i.key,
3595
+ mode,
3596
+ variant,
3597
+ scrollTop: textBody.scrollTop,
3598
+ viewportRows,
3599
+ total: textBody.lines.length
3600
+ }
3601
+ ),
3315
3602
  /* @__PURE__ */ jsx6(Text5, { wrap: "truncate", children: i.name }),
3316
- /* @__PURE__ */ jsx6(IssueMeta, { issue: i }),
3603
+ /* @__PURE__ */ jsx6(IssueMeta, { issue: i, timeInState: props.timeInState }),
3317
3604
  /* @__PURE__ */ jsx6(
3318
- DescriptionBody,
3605
+ DetailBody,
3319
3606
  {
3320
- loading: props.loading ?? false,
3321
- hasDescription: Boolean(i.description),
3322
- visible,
3323
- hiddenAbove: scrollTop,
3324
- hiddenBelow: Math.max(0, lines.length - scrollTop - viewportRows),
3325
- scrollTop
3607
+ mode,
3608
+ textBody,
3609
+ relations: props.relations ?? [],
3610
+ relationsSelected: props.relationsSelected ?? 0,
3611
+ relationsLoading: props.relationsLoading ?? false,
3612
+ relationsViewport
3326
3613
  }
3327
3614
  )
3328
3615
  ]
@@ -4714,6 +5001,183 @@ function useDetailPanel(opts) {
4714
5001
  };
4715
5002
  }
4716
5003
 
5004
+ // src/tui/use-activity-log.ts
5005
+ import { useEffect as useEffect5, useMemo as useMemo6, useState as useState11 } from "react";
5006
+
5007
+ // src/types/activity.ts
5008
+ var STATE_FIELD = "state";
5009
+ function isStateChange(activity) {
5010
+ return activity.field === STATE_FIELD;
5011
+ }
5012
+
5013
+ // src/plane/state-duration.ts
5014
+ function currentStateEntry(activities, issueCreatedAt) {
5015
+ const transitions = activities.filter(isStateChange).map((a) => a.createdAt).sort();
5016
+ return transitions.at(-1) ?? issueCreatedAt;
5017
+ }
5018
+ function timeInCurrentState(activities, issueCreatedAt, now) {
5019
+ const entry = Date.parse(currentStateEntry(activities, issueCreatedAt));
5020
+ if (Number.isNaN(entry)) return 0;
5021
+ return Math.max(0, now - entry);
5022
+ }
5023
+
5024
+ // src/tui/use-activity-log.ts
5025
+ function useActivityLog(opts) {
5026
+ const { open: open2, target, createdAt, ctx, logger } = opts;
5027
+ const [activities, setActivities] = useState11();
5028
+ const [loading, setLoading] = useState11(false);
5029
+ useEffect5(() => {
5030
+ if (!open2 || !target) {
5031
+ setActivities(void 0);
5032
+ return;
5033
+ }
5034
+ const controller = new AbortController();
5035
+ setLoading(true);
5036
+ const project = {
5037
+ id: target.project_id,
5038
+ identifier: target.project_identifier,
5039
+ name: "",
5040
+ workspace_id: ""
5041
+ };
5042
+ ctx.activities.list(project, target.id, controller.signal).then((log) => {
5043
+ if (!controller.signal.aborted) setActivities(log);
5044
+ }).catch((err) => {
5045
+ if (controller.signal.aborted) return;
5046
+ logger.warn("activity log fetch failed", { issue: target.key, err });
5047
+ setActivities(void 0);
5048
+ }).finally(() => {
5049
+ if (!controller.signal.aborted) setLoading(false);
5050
+ });
5051
+ return () => {
5052
+ controller.abort();
5053
+ };
5054
+ }, [open2, target, ctx, logger]);
5055
+ const stateChanges = useMemo6(
5056
+ () => activities ? activities.filter(isStateChange) : [],
5057
+ [activities]
5058
+ );
5059
+ const timeInState = useMemo6(() => {
5060
+ if (!activities || !createdAt) return void 0;
5061
+ return humanizeDuration(timeInCurrentState(activities, createdAt, Date.now()));
5062
+ }, [activities, createdAt]);
5063
+ return { activities, stateChanges, loading, timeInState };
5064
+ }
5065
+
5066
+ // src/tui/use-relations.ts
5067
+ import { useEffect as useEffect6, useMemo as useMemo7, useState as useState12 } from "react";
5068
+
5069
+ // src/plane/relation-view.ts
5070
+ function indexAddEvents(activities) {
5071
+ const byTarget = /* @__PURE__ */ new Map();
5072
+ for (const a of activities) {
5073
+ if (!a.field || !RELATION_TYPES.includes(a.field)) continue;
5074
+ const target = a.oldIdentifier;
5075
+ if (!target) continue;
5076
+ const existing = byTarget.get(target);
5077
+ if (!existing || a.createdAt > existing.relatedAt) {
5078
+ byTarget.set(target, { relatedAt: a.createdAt, key: a.newValue });
5079
+ }
5080
+ }
5081
+ return byTarget;
5082
+ }
5083
+ function buildRelations(relations, activities) {
5084
+ const addEvents = indexAddEvents(activities);
5085
+ const result = [];
5086
+ for (const type of RELATION_TYPES) {
5087
+ for (const targetId of relations[type] ?? []) {
5088
+ const add = addEvents.get(targetId);
5089
+ result.push({ type, targetId, targetKey: add?.key, relatedAt: add?.relatedAt });
5090
+ }
5091
+ }
5092
+ return result;
5093
+ }
5094
+
5095
+ // src/tui/use-relations.ts
5096
+ function useRelations(opts) {
5097
+ const { open: open2, target, activities, ctx, logger } = opts;
5098
+ const [relations, setRelations] = useState12();
5099
+ const [loading, setLoading] = useState12(false);
5100
+ const [targets, setTargets] = useState12({});
5101
+ useEffect6(() => {
5102
+ if (!open2 || !target) {
5103
+ setRelations(void 0);
5104
+ setTargets({});
5105
+ return;
5106
+ }
5107
+ const controller = new AbortController();
5108
+ setLoading(true);
5109
+ const project = {
5110
+ id: target.project_id,
5111
+ identifier: target.project_identifier,
5112
+ name: "",
5113
+ workspace_id: ""
5114
+ };
5115
+ ctx.relations.list(project, target.id, controller.signal).then((current) => {
5116
+ if (!controller.signal.aborted) setRelations(buildRelations(current, activities ?? []));
5117
+ }).catch((err) => {
5118
+ if (controller.signal.aborted) return;
5119
+ logger.warn("relations fetch failed", { issue: target.key, err });
5120
+ setRelations(void 0);
5121
+ }).finally(() => {
5122
+ if (!controller.signal.aborted) setLoading(false);
5123
+ });
5124
+ return () => {
5125
+ controller.abort();
5126
+ };
5127
+ }, [open2, target, activities, ctx, logger]);
5128
+ useEffect6(() => {
5129
+ if (!relations) return;
5130
+ let cancelled = false;
5131
+ const pending = relations.map((r) => r.targetKey).filter((key) => Boolean(key) && !(key in targets));
5132
+ for (const key of pending) {
5133
+ ctx.issues.view(key).then((issue) => {
5134
+ if (!cancelled) setTargets((prev) => ({ ...prev, [key]: issue }));
5135
+ }).catch((err) => {
5136
+ logger.warn("relation target resolve failed", { key, err });
5137
+ });
5138
+ }
5139
+ return () => {
5140
+ cancelled = true;
5141
+ };
5142
+ }, [relations, targets, ctx, logger]);
5143
+ const enriched = useMemo7(
5144
+ () => (relations ?? []).map(
5145
+ (r) => r.targetKey && targets[r.targetKey] ? { ...r, target: targets[r.targetKey] } : r
5146
+ ),
5147
+ [relations, targets]
5148
+ );
5149
+ return { relations: enriched, loading };
5150
+ }
5151
+
5152
+ // src/tui/use-detail-stack.ts
5153
+ import { useCallback as useCallback6, useState as useState13 } from "react";
5154
+ function targetFromIssue(issue) {
5155
+ return {
5156
+ id: issue.id,
5157
+ key: issue.key,
5158
+ project_id: issue.project_id,
5159
+ project_identifier: issue.project_identifier
5160
+ };
5161
+ }
5162
+ function useDetailStack() {
5163
+ const [stack, setStack] = useState13([]);
5164
+ const open2 = useCallback6((target) => setStack([target]), []);
5165
+ const push = useCallback6(
5166
+ (target) => setStack((s) => s.at(-1)?.id === target.id ? s : [...s, target]),
5167
+ []
5168
+ );
5169
+ const pop = useCallback6(() => setStack((s) => s.slice(0, -1)), []);
5170
+ const close = useCallback6(() => setStack([]), []);
5171
+ return {
5172
+ current: stack.at(-1),
5173
+ canGoBack: stack.length > 1,
5174
+ open: open2,
5175
+ push,
5176
+ pop,
5177
+ close
5178
+ };
5179
+ }
5180
+
4717
5181
  // src/tui/dashboard.tsx
4718
5182
  import { jsx as jsx13, jsxs as jsxs12 } from "react/jsx-runtime";
4719
5183
  var NARROW_BREAKPOINT = 100;
@@ -4863,6 +5327,13 @@ function renderActiveOverlay(opts) {
4863
5327
  issue: current,
4864
5328
  loading: detail.loading,
4865
5329
  variant: "modal",
5330
+ mode: opts.detailMode,
5331
+ timeInState: opts.activity.timeInState,
5332
+ stateChanges: opts.activity.stateChanges,
5333
+ activityLoading: opts.activity.loading,
5334
+ relations: opts.relations.relations,
5335
+ relationsSelected: opts.relationsSelected,
5336
+ relationsLoading: opts.relations.loading,
4866
5337
  scrollTop: detail.scroll,
4867
5338
  viewportRows: opts.detailViewportRows,
4868
5339
  height: opts.detailModalHeight
@@ -4903,19 +5374,22 @@ function Dashboard({ ctx, logger }) {
4903
5374
  const { exit } = useApp();
4904
5375
  const { rows: terminalRows, columns: terminalCols } = useTerminalSize();
4905
5376
  const narrow = isNarrowLayout(terminalCols);
4906
- const views = useMemo6(() => ctx.runtime.profile.views ?? [], [ctx]);
4907
- const defaultProjects = useMemo6(() => ctx.runtime.profile.defaults?.projects ?? [], [ctx]);
4908
- const viewEntries = useMemo6(
5377
+ const views = useMemo8(() => ctx.runtime.profile.views ?? [], [ctx]);
5378
+ const defaultProjects = useMemo8(() => ctx.runtime.profile.defaults?.projects ?? [], [ctx]);
5379
+ const viewEntries = useMemo8(
4909
5380
  () => buildViewEntries(views, defaultProjects),
4910
5381
  [views, defaultProjects]
4911
5382
  );
4912
- const [viewIdx, setViewIdx] = useState11(0);
4913
- const [selected, setSelected] = useState11(0);
4914
- const [statusMessage, setStatusMessage] = useState11();
4915
- const [panel, setPanel] = useState11("list");
4916
- const [helpOpen, setHelpOpen] = useState11(false);
5383
+ const [viewIdx, setViewIdx] = useState14(0);
5384
+ const [selected, setSelected] = useState14(0);
5385
+ const [statusMessage, setStatusMessage] = useState14();
5386
+ const stack = useDetailStack();
5387
+ const detailOpen = stack.current !== void 0;
5388
+ const [detailMode, setDetailMode] = useState14("detail");
5389
+ const [relationsSelected, setRelationsSelected] = useState14(0);
5390
+ const [helpOpen, setHelpOpen] = useState14(false);
4917
5391
  const activeView = views[viewIdx];
4918
- const { layout: activeLayout, sort: activeSort } = useMemo6(
5392
+ const { layout: activeLayout, sort: activeSort } = useMemo8(
4919
5393
  () => resolveViewPresentation(activeView, ctx.runtime.profile.defaults),
4920
5394
  [activeView, ctx]
4921
5395
  );
@@ -4937,7 +5411,7 @@ function Dashboard({ ctx, logger }) {
4937
5411
  const loading = active.loading;
4938
5412
  const partialMessage = active.failedProjects.length > 0 ? `partial: ${active.failedProjects.length} project(s) unavailable (${active.failedProjects.join(", ")})` : void 0;
4939
5413
  const error = statusMessage ?? active.error ?? partialMessage;
4940
- const navbarEntries = useMemo6(
5414
+ const navbarEntries = useMemo8(
4941
5415
  () => viewEntries.map((entry, idx) => {
4942
5416
  const data = viewsData.byView[idx];
4943
5417
  return {
@@ -4949,7 +5423,7 @@ function Dashboard({ ctx, logger }) {
4949
5423
  [viewEntries, viewsData.byView]
4950
5424
  );
4951
5425
  const loadView = viewsData.load;
4952
- const load = useCallback6(
5426
+ const load = useCallback7(
4953
5427
  async (preserveSelection = false) => {
4954
5428
  const previousKey = preserveSelection ? selectedKeyRef.current : void 0;
4955
5429
  const keys = await loadView(viewIdx);
@@ -4959,12 +5433,12 @@ function Dashboard({ ctx, logger }) {
4959
5433
  );
4960
5434
  const loadedRef = React7.useRef(viewsData.byView);
4961
5435
  loadedRef.current = viewsData.byView;
4962
- useEffect5(() => {
5436
+ useEffect7(() => {
4963
5437
  if (!activeView) return;
4964
5438
  if (loadedRef.current[viewIdx]?.loaded) setSelected(0);
4965
5439
  else void load();
4966
5440
  }, [viewIdx, activeView, load]);
4967
- useEffect5(() => {
5441
+ useEffect7(() => {
4968
5442
  logger.info("dashboard started", {
4969
5443
  profile: ctx.runtime.profile_name,
4970
5444
  workspace: ctx.runtime.profile.server.workspace_slug,
@@ -4986,7 +5460,7 @@ function Dashboard({ ctx, logger }) {
4986
5460
  logger
4987
5461
  });
4988
5462
  const currentSummary = filtered[selected];
4989
- useEffect5(() => {
5463
+ useEffect7(() => {
4990
5464
  selectedKeyRef.current = currentSummary?.key;
4991
5465
  }, [currentSummary]);
4992
5466
  const comments = useCommentEditor({
@@ -5008,7 +5482,7 @@ function Dashboard({ ctx, logger }) {
5008
5482
  name: "",
5009
5483
  workspace_id: ""
5010
5484
  });
5011
- const reconcile = useCallback6(
5485
+ const reconcile = useCallback7(
5012
5486
  (updated, touchesFilter) => {
5013
5487
  if (touchesFilter) void load(true);
5014
5488
  else viewsData.patchIssue(viewIdx, updated);
@@ -5036,7 +5510,7 @@ function Dashboard({ ctx, logger }) {
5036
5510
  setStatusMessage(message);
5037
5511
  }
5038
5512
  });
5039
- const activeProjects = useMemo6(
5513
+ const activeProjects = useMemo8(
5040
5514
  () => resolveViewProjectsLenient(activeView ?? { name: "" }, defaultProjects).projects,
5041
5515
  [activeView, defaultProjects]
5042
5516
  );
@@ -5082,11 +5556,11 @@ function Dashboard({ ctx, logger }) {
5082
5556
  creator.active,
5083
5557
  transition.active,
5084
5558
  helpOpen,
5085
- panel === "detail",
5559
+ detailOpen,
5086
5560
  filtering
5087
5561
  ].some(Boolean);
5088
5562
  const intervalMs = autoRefreshIntervalMs(ctx.runtime.profile.defaults?.auto_refresh_seconds);
5089
- useEffect5(() => {
5563
+ useEffect7(() => {
5090
5564
  if (intervalMs === void 0 || overlayActive || !activeView) return;
5091
5565
  const timer = setInterval(() => {
5092
5566
  void load(true);
@@ -5094,12 +5568,30 @@ function Dashboard({ ctx, logger }) {
5094
5568
  return () => clearInterval(timer);
5095
5569
  }, [intervalMs, overlayActive, activeView, load]);
5096
5570
  const detail = useDetailPanel({
5097
- open: panel === "detail",
5098
- target: currentSummary,
5571
+ open: detailOpen,
5572
+ target: stack.current,
5099
5573
  ctx,
5100
5574
  logger,
5101
5575
  setMessage: setStatusMessage
5102
5576
  });
5577
+ const activity = useActivityLog({
5578
+ open: detailOpen,
5579
+ target: stack.current,
5580
+ createdAt: detail.detailed?.created_at,
5581
+ ctx,
5582
+ logger
5583
+ });
5584
+ const relations = useRelations({
5585
+ open: detailOpen,
5586
+ target: stack.current,
5587
+ activities: activity.activities,
5588
+ ctx,
5589
+ logger
5590
+ });
5591
+ useEffect7(() => {
5592
+ setDetailMode("detail");
5593
+ setRelationsSelected(0);
5594
+ }, [stack.current]);
5103
5595
  const viewportRows = listViewportRows({
5104
5596
  terminalRows,
5105
5597
  filtering,
@@ -5109,7 +5601,7 @@ function Dashboard({ ctx, logger }) {
5109
5601
  const STATUS_BAR_ROWS = 3;
5110
5602
  const detailModalHeight = Math.max(DETAIL_CHROME_ROWS + 3, terminalRows - STATUS_BAR_ROWS);
5111
5603
  const detailViewportRows = Math.max(3, detailModalHeight - DETAIL_CHROME_ROWS);
5112
- const openSelectedInBrowser = useCallback6(() => {
5604
+ const openSelectedInBrowser = useCallback7(() => {
5113
5605
  const issue = filtered[selected];
5114
5606
  if (!issue) return;
5115
5607
  try {
@@ -5142,7 +5634,9 @@ function Dashboard({ ctx, logger }) {
5142
5634
  "list.page-up": () => setSelected((s) => Math.max(0, s - viewportRows)),
5143
5635
  "list.top": () => setSelected(0),
5144
5636
  "list.bottom": () => setSelected(Math.max(0, filtered.length - 1)),
5145
- "list.open-detail": () => setPanel("detail"),
5637
+ "list.open-detail": () => {
5638
+ if (currentSummary) stack.open(targetFromIssue(currentSummary));
5639
+ },
5146
5640
  "list.open-browser": openSelectedInBrowser,
5147
5641
  "list.comment": comments.open,
5148
5642
  "list.edit": editor.open,
@@ -5165,13 +5659,29 @@ function Dashboard({ ctx, logger }) {
5165
5659
  );
5166
5660
  if (!consumed && (input2 === "q" || key.escape)) setHelpOpen(false);
5167
5661
  };
5662
+ const openFocusedRelation = useCallback7(() => {
5663
+ const relation = relations.relations[relationsSelected];
5664
+ if (!relation) return;
5665
+ if (relation.target) {
5666
+ stack.push(targetFromIssue(relation.target));
5667
+ return;
5668
+ }
5669
+ if (!relation.targetKey) return;
5670
+ void ctx.issues.view(relation.targetKey).then((issue) => stack.push(targetFromIssue(issue))).catch((err) => {
5671
+ logger.error("open relation failed", { key: relation.targetKey, err });
5672
+ setStatusMessage(`open ${relation.targetKey}: ${err.message}`);
5673
+ });
5674
+ }, [relations.relations, relationsSelected, stack, ctx, logger]);
5168
5675
  const handleDetailKey = (input2, key) => {
5676
+ const relationsMode = detailMode === "relations";
5677
+ const relationCount = relations.relations.length;
5169
5678
  const detailHandlers = {
5170
- "detail.close": () => setPanel("list"),
5171
- "detail.scroll-down": () => detail.scrollBy(1),
5172
- "detail.scroll-down-alt": () => detail.scrollBy(1),
5173
- "detail.scroll-up": () => detail.scrollBy(-1),
5174
- "detail.scroll-up-alt": () => detail.scrollBy(-1),
5679
+ // esc pops the navigation stack: back to the issue we came from, or close.
5680
+ "detail.close": () => stack.pop(),
5681
+ "detail.scroll-down": relationsMode ? () => setRelationsSelected((s) => Math.min(relationCount - 1, s + 1)) : () => detail.scrollBy(1),
5682
+ "detail.scroll-down-alt": relationsMode ? () => setRelationsSelected((s) => Math.min(relationCount - 1, s + 1)) : () => detail.scrollBy(1),
5683
+ "detail.scroll-up": relationsMode ? () => setRelationsSelected((s) => Math.max(0, s - 1)) : () => detail.scrollBy(-1),
5684
+ "detail.scroll-up-alt": relationsMode ? () => setRelationsSelected((s) => Math.max(0, s - 1)) : () => detail.scrollBy(-1),
5175
5685
  "detail.page-down": () => detail.scrollBy(detailViewportRows),
5176
5686
  "detail.page-up": () => detail.scrollBy(-detailViewportRows),
5177
5687
  "detail.top": detail.scrollTop,
@@ -5179,10 +5689,24 @@ function Dashboard({ ctx, logger }) {
5179
5689
  "detail.open-browser": openSelectedInBrowser,
5180
5690
  "detail.comment": comments.open,
5181
5691
  "detail.edit": editor.open,
5692
+ // Toggle the activity / relations bodies; toggling back returns to the
5693
+ // description. Reset the scroll so the new body starts at the top.
5694
+ "detail.activity": () => {
5695
+ setDetailMode((mode) => mode === "activity" ? "detail" : "activity");
5696
+ detail.scrollTop();
5697
+ },
5698
+ "detail.relations": () => {
5699
+ setDetailMode((mode) => mode === "relations" ? "detail" : "relations");
5700
+ setRelationsSelected(0);
5701
+ detail.scrollTop();
5702
+ },
5703
+ // enter only acts in the relations body, opening the focused relation.
5704
+ "detail.relation-open": relationsMode ? openFocusedRelation : () => {
5705
+ },
5182
5706
  "global.help": () => setHelpOpen((open2) => !open2),
5183
5707
  "global.refresh": () => void load(true),
5184
5708
  "global.refresh-all": () => viewsData.refreshAll(),
5185
- "global.quit": () => setPanel("list")
5709
+ "global.quit": () => stack.close()
5186
5710
  };
5187
5711
  dispatch(ctx.keybindings, ["detail", "global"], detailHandlers, input2, key);
5188
5712
  };
@@ -5192,7 +5716,7 @@ function Dashboard({ ctx, logger }) {
5192
5716
  [editor.active, editor.handleKey],
5193
5717
  [comments.active, comments.handleKey],
5194
5718
  [helpOpen, handleHelpKey],
5195
- [panel === "detail", handleDetailKey],
5719
+ [detailOpen, handleDetailKey],
5196
5720
  [filtering, handleFilterKey]
5197
5721
  ];
5198
5722
  useInput2((input2, key) => {
@@ -5200,7 +5724,7 @@ function Dashboard({ ctx, logger }) {
5200
5724
  if (route) return route[1](input2, key);
5201
5725
  dispatch(ctx.keybindings, ["global", "list", "view", "filter"], handlers, input2, key);
5202
5726
  });
5203
- const current = detail.detailed ?? currentSummary;
5727
+ const current = detail.detailed ?? (stack.canGoBack ? void 0 : currentSummary);
5204
5728
  const statusBarBase = {
5205
5729
  profile: ctx.runtime.profile_name,
5206
5730
  workspace: ctx.runtime.profile.server.workspace_slug,
@@ -5213,13 +5737,17 @@ function Dashboard({ ctx, logger }) {
5213
5737
  total: issues.length,
5214
5738
  filtering: Boolean(filter)
5215
5739
  });
5216
- const isDetail = panel === "detail";
5740
+ const isDetail = detailOpen;
5217
5741
  const overlay = renderActiveOverlay({
5218
5742
  transition,
5219
5743
  creator,
5220
5744
  editor,
5221
5745
  comments,
5222
5746
  detail,
5747
+ activity,
5748
+ relations,
5749
+ relationsSelected,
5750
+ detailMode,
5223
5751
  helpOpen,
5224
5752
  isDetail,
5225
5753
  currentSummary,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plc-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "CLI and TUI for Plane (Cloud and self-hosted), inspired by gh and gh dash.",
5
5
  "license": "MIT",
6
6
  "author": "Bruno Mariano",