trelly 0.1.0 → 0.2.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.
@@ -1,6 +1,10 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
1
3
  import { Box, Text, useApp, useInput, useStdout } from "ink";
2
- import { useCallback, useEffect, useState } from "react";
4
+ import { useCallback, useEffect, useRef, useState } from "react";
3
5
  import type { TrelloClient } from "../../api/client.ts";
6
+ import { attachmentForm } from "../../util/attachment.ts";
7
+ import { openInBrowser } from "../../util/runtime.ts";
4
8
  import {
5
9
  type CardChip,
6
10
  customFieldChips,
@@ -51,13 +55,19 @@ type BoardData = {
51
55
  };
52
56
 
53
57
  type CardExtras = {
54
- attachments: Array<{ id: string; name: string }>;
58
+ attachments: Array<{ id: string; name: string; url: string }>;
55
59
  checklists: Array<{
56
60
  id: string;
57
61
  name: string;
58
62
  items: Array<{ id: string; name: string; complete: boolean }>;
59
63
  }>;
60
- comments: Array<{ id: string; author: string; date: string; text: string }>;
64
+ comments: Array<{
65
+ id: string;
66
+ author: string;
67
+ username?: string;
68
+ date: string;
69
+ text: string;
70
+ }>;
61
71
  error?: string;
62
72
  };
63
73
 
@@ -215,6 +225,8 @@ function Column({
215
225
  );
216
226
  }
217
227
 
228
+ type DetailMode = "view" | "comment" | "reply" | "attach";
229
+
218
230
  function CardDetail({
219
231
  card,
220
232
  listName,
@@ -222,6 +234,9 @@ function CardDetail({
222
234
  defs,
223
235
  membersById,
224
236
  extras,
237
+ client,
238
+ onClose,
239
+ onChanged,
225
240
  }: {
226
241
  card: UiCard;
227
242
  listName: string;
@@ -229,6 +244,9 @@ function CardDetail({
229
244
  defs: UiCustomFieldDef[];
230
245
  membersById: Map<string, string>;
231
246
  extras?: CardExtras;
247
+ client: TrelloClient;
248
+ onClose: () => void;
249
+ onChanged: () => void;
232
250
  }) {
233
251
  const status = dueStatus(card.due, card.dueComplete);
234
252
  const checkItems = card.badges?.checkItems ?? 0;
@@ -238,6 +256,131 @@ function CardDetail({
238
256
  const memberNames = (card.idMembers ?? [])
239
257
  .map((id) => membersById.get(id))
240
258
  .filter((name): name is string => Boolean(name));
259
+
260
+ const [focus, setFocus] = useState(0);
261
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
262
+ const [mode, setMode] = useState<DetailMode>("view");
263
+ const [buffer, setBuffer] = useState("");
264
+ const [busy, setBusy] = useState(false);
265
+ const [notice, setNotice] = useState<{ text: string; error?: boolean } | null>(null);
266
+ // refs mirror state so several key events in one tick see current values
267
+ const bufferRef = useRef("");
268
+ const busyRef = useRef(false);
269
+ const setComposerBuffer = useCallback((value: string) => {
270
+ bufferRef.current = value;
271
+ setBuffer(value);
272
+ }, []);
273
+
274
+ const attachments = extras?.attachments ?? [];
275
+ const comments = extras?.comments ?? [];
276
+ const itemCount = attachments.length + comments.length;
277
+ const safeFocus = itemCount === 0 ? -1 : Math.min(focus, itemCount - 1);
278
+ const focusedAttachment =
279
+ safeFocus >= 0 && safeFocus < attachments.length
280
+ ? attachments[safeFocus]
281
+ : undefined;
282
+ const focusedComment =
283
+ safeFocus >= attachments.length
284
+ ? comments[safeFocus - attachments.length]
285
+ : undefined;
286
+
287
+ const submit = useCallback(
288
+ async (raw: string) => {
289
+ const text = raw.trim();
290
+ if (!text || busyRef.current) return;
291
+ busyRef.current = true;
292
+ setBusy(true);
293
+ setNotice(null);
294
+ try {
295
+ if (mode === "attach") {
296
+ if (/^https?:\/\//i.test(text)) {
297
+ await client.cardAddAttachment(card.id, { url: text });
298
+ } else {
299
+ const path = text.startsWith("~/") ? join(homedir(), text.slice(2)) : text;
300
+ await client.cardUploadAttachment(card.id, attachmentForm(path));
301
+ }
302
+ setNotice({ text: "✓ attachment added" });
303
+ } else {
304
+ await client.cardComment(card.id, text);
305
+ setNotice({ text: "✓ comment added" });
306
+ }
307
+ setMode("view");
308
+ setComposerBuffer("");
309
+ onChanged();
310
+ } catch (err) {
311
+ setNotice({
312
+ text: err instanceof Error ? err.message : String(err),
313
+ error: true,
314
+ });
315
+ } finally {
316
+ busyRef.current = false;
317
+ setBusy(false);
318
+ }
319
+ },
320
+ [mode, client, card.id, onChanged, setComposerBuffer],
321
+ );
322
+
323
+ useInput((input, key) => {
324
+ if (busyRef.current) return;
325
+ if (mode !== "view") {
326
+ if (key.escape) {
327
+ setMode("view");
328
+ setComposerBuffer("");
329
+ return;
330
+ }
331
+ if (key.backspace || key.delete) {
332
+ setComposerBuffer(bufferRef.current.slice(0, -1));
333
+ return;
334
+ }
335
+ // paste can deliver text and the newline in one event; append before submit
336
+ const typed = input && !key.ctrl && !key.meta ? input.replace(/[\r\n]/g, "") : "";
337
+ if (typed) setComposerBuffer(bufferRef.current + typed);
338
+ if (key.return) void submit(bufferRef.current);
339
+ return;
340
+ }
341
+ if (key.escape || input === "q") {
342
+ onClose();
343
+ } else if (key.upArrow || input === "k") {
344
+ if (safeFocus > 0) setFocus(safeFocus - 1);
345
+ } else if (key.downArrow || input === "j") {
346
+ if (safeFocus >= 0 && safeFocus < itemCount - 1) setFocus(safeFocus + 1);
347
+ } else if (key.return && focusedAttachment) {
348
+ openInBrowser(focusedAttachment.url).then(
349
+ () => setNotice({ text: `✓ opened ${focusedAttachment.name}` }),
350
+ (err) =>
351
+ setNotice({
352
+ text: err instanceof Error ? err.message : String(err),
353
+ error: true,
354
+ }),
355
+ );
356
+ } else if (key.return && focusedComment) {
357
+ setExpanded((prev) => {
358
+ const next = new Set(prev);
359
+ if (next.has(focusedComment.id)) next.delete(focusedComment.id);
360
+ else next.add(focusedComment.id);
361
+ return next;
362
+ });
363
+ } else if (input === "c") {
364
+ setMode("comment");
365
+ setComposerBuffer("");
366
+ setNotice(null);
367
+ } else if (input === "r" && focusedComment) {
368
+ setMode("reply");
369
+ setComposerBuffer(focusedComment.username ? `@${focusedComment.username} ` : "");
370
+ setNotice(null);
371
+ } else if (input === "a") {
372
+ setMode("attach");
373
+ setComposerBuffer("");
374
+ setNotice(null);
375
+ }
376
+ });
377
+
378
+ const commentFocus = safeFocus - attachments.length;
379
+ const maxComments = 5;
380
+ const commentStart = commentFocus >= maxComments ? commentFocus - maxComments + 1 : 0;
381
+ const visibleComments = comments.slice(commentStart, commentStart + maxComments);
382
+ const commentsBelow = comments.length - (commentStart + visibleComments.length);
383
+
241
384
  return (
242
385
  <Box flexDirection="column" paddingX={1} width={Math.min(width, 82)}>
243
386
  <Text bold wrap="truncate">
@@ -315,34 +458,77 @@ function CardDetail({
315
458
  ) : null}
316
459
  </Box>
317
460
  ))}
318
- {extras.attachments.length > 0 ? (
461
+ {attachments.length > 0 ? (
319
462
  <Box flexDirection="column" marginTop={1}>
320
- <Text bold>📎 {extras.attachments.length} attachments</Text>
321
- {extras.attachments.slice(0, 4).map((attachment) => (
322
- <Text key={attachment.id} dimColor wrap="truncate">
323
- {attachment.name}
463
+ <Text bold>📎 {attachments.length} attachments</Text>
464
+ {attachments.map((attachment, i) => (
465
+ <Text key={attachment.id} wrap="truncate">
466
+ {safeFocus === i ? <Text color={TRELLO_BLUE}>❯ </Text> : " "}
467
+ <Text bold={safeFocus === i} dimColor={safeFocus !== i}>
468
+ {attachment.name}
469
+ </Text>
324
470
  </Text>
325
471
  ))}
326
- {extras.attachments.length > 4 ? (
327
- <Text dimColor>… {extras.attachments.length - 4} more</Text>
328
- ) : null}
329
472
  </Box>
330
473
  ) : null}
331
- {extras.comments.length > 0 ? (
474
+ {comments.length > 0 ? (
332
475
  <Box flexDirection="column" marginTop={1}>
333
476
  <Text bold>💬 comments</Text>
334
- {extras.comments.slice(0, 3).map((comment) => (
335
- <Box key={comment.id} flexDirection="column">
336
- <Text dimColor wrap="truncate">
337
- {comment.author} · {comment.date.slice(0, 16).replace("T", " ")}
338
- </Text>
339
- <Text wrap="truncate-end">{truncateLines(comment.text, 2)}</Text>
340
- </Box>
341
- ))}
477
+ {commentStart > 0 ? <Text dimColor> ↑ {commentStart} more</Text> : null}
478
+ {visibleComments.map((comment, i) => {
479
+ const focused = commentFocus === commentStart + i;
480
+ const isExpanded = expanded.has(comment.id);
481
+ return (
482
+ <Box key={comment.id} flexDirection="column">
483
+ <Text wrap="truncate">
484
+ {focused ? <Text color={TRELLO_BLUE}>❯ </Text> : " "}
485
+ <Text dimColor bold={focused}>
486
+ {comment.author} · {comment.date.slice(0, 16).replace("T", " ")}
487
+ </Text>
488
+ </Text>
489
+ <Box paddingLeft={2}>
490
+ <Text wrap={isExpanded ? "wrap" : "truncate-end"}>
491
+ {isExpanded ? comment.text : truncateLines(comment.text, 2)}
492
+ </Text>
493
+ </Box>
494
+ </Box>
495
+ );
496
+ })}
497
+ {commentsBelow > 0 ? <Text dimColor> ↓ {commentsBelow} more</Text> : null}
342
498
  </Box>
343
499
  ) : null}
344
500
  </>
345
501
  )}
502
+ {mode !== "view" ? (
503
+ <Box flexDirection="column" marginTop={1}>
504
+ <Text bold>
505
+ {mode === "attach"
506
+ ? "attach (file path or URL)"
507
+ : mode === "reply"
508
+ ? "reply"
509
+ : "comment"}
510
+ </Text>
511
+ <Text>
512
+ {"> "}
513
+ {buffer}
514
+ <Text inverse> </Text>
515
+ </Text>
516
+ </Box>
517
+ ) : null}
518
+ {busy ? (
519
+ <Text dimColor>sending…</Text>
520
+ ) : notice ? (
521
+ <Text color={notice.error ? "#eb5a46" : undefined} dimColor={!notice.error}>
522
+ {notice.text}
523
+ </Text>
524
+ ) : null}
525
+ <Box marginTop={1}>
526
+ <Text dimColor>
527
+ {mode !== "view"
528
+ ? "⏎ send · esc cancel"
529
+ : "↑↓ move · ⏎ open/expand · c comment · r reply · a attach · esc back"}
530
+ </Text>
531
+ </Box>
346
532
  </Box>
347
533
  );
348
534
  }
@@ -415,12 +601,12 @@ function BoardView({
415
601
  }, [load]);
416
602
 
417
603
  const loadExtras = useCallback(
418
- async (card: UiCard) => {
419
- if (extras.has(card.id)) return;
604
+ async (card: UiCard, force = false) => {
605
+ if (!force && extras.has(card.id)) return;
420
606
  try {
421
607
  const [attachments, checklists, actions] = await Promise.all([
422
608
  client.cardAttachments(card.id) as Promise<
423
- Array<{ id: string; name: string }>
609
+ Array<{ id: string; name: string; url: string }>
424
610
  >,
425
611
  client.get(`/cards/${card.id}/checklists`) as Promise<
426
612
  Array<{
@@ -429,7 +615,7 @@ function BoardView({
429
615
  checkItems?: Array<{ id: string; name: string; state?: string }>;
430
616
  }>
431
617
  >,
432
- client.cardActions(card.id, { filter: "commentCard", limit: 5 }) as Promise<
618
+ client.cardActions(card.id, { filter: "commentCard", limit: 20 }) as Promise<
433
619
  Array<{
434
620
  id: string;
435
621
  date: string;
@@ -440,7 +626,11 @@ function BoardView({
440
626
  ]);
441
627
  setExtras((prev) =>
442
628
  new Map(prev).set(card.id, {
443
- attachments: attachments.map((a) => ({ id: a.id, name: a.name })),
629
+ attachments: attachments.map((a) => ({
630
+ id: a.id,
631
+ name: a.name,
632
+ url: a.url,
633
+ })),
444
634
  checklists: checklists.map((cl) => ({
445
635
  id: cl.id,
446
636
  name: cl.name,
@@ -456,6 +646,7 @@ function BoardView({
456
646
  action.memberCreator?.fullName ??
457
647
  action.memberCreator?.username ??
458
648
  "unknown",
649
+ username: action.memberCreator?.username,
459
650
  date: action.date,
460
651
  text: action.data?.text ?? "",
461
652
  })),
@@ -475,44 +666,43 @@ function BoardView({
475
666
  [client, extras],
476
667
  );
477
668
 
478
- useInput((input, key) => {
479
- if (detail) {
480
- if (key.escape || key.return || input === "q") setDetail(false);
481
- return;
482
- }
483
- if (input === "q") {
484
- exit();
485
- return;
486
- }
487
- if (input === "r") {
488
- void load();
489
- return;
490
- }
491
- if ((key.escape || key.backspace) && onBack) {
492
- onBack();
493
- return;
494
- }
495
- if (!data || data.lists.length === 0) return;
496
- const lists = data.lists;
497
- const safeCol = Math.min(col, lists.length - 1);
498
- const cards = data.cardsByList.get(lists[safeCol].id) ?? [];
499
- const safeRow = cards.length === 0 ? -1 : Math.min(row, cards.length - 1);
500
- if (key.leftArrow || input === "h") {
501
- setCol(Math.max(0, safeCol - 1));
502
- setRow(0);
503
- } else if (key.rightArrow || input === "l") {
504
- setCol(Math.min(lists.length - 1, safeCol + 1));
505
- setRow(0);
506
- } else if (key.upArrow || input === "k") {
507
- if (safeRow > 0) setRow(safeRow - 1);
508
- } else if (key.downArrow || input === "j") {
509
- if (safeRow >= 0 && safeRow < cards.length - 1) setRow(safeRow + 1);
510
- } else if (key.return && safeRow >= 0) {
511
- const focusedCard = cards[safeRow];
512
- if (focusedCard) void loadExtras(focusedCard);
513
- setDetail(true);
514
- }
515
- });
669
+ useInput(
670
+ (input, key) => {
671
+ if (input === "q") {
672
+ exit();
673
+ return;
674
+ }
675
+ if (input === "r") {
676
+ void load();
677
+ return;
678
+ }
679
+ if ((key.escape || key.backspace) && onBack) {
680
+ onBack();
681
+ return;
682
+ }
683
+ if (!data || data.lists.length === 0) return;
684
+ const lists = data.lists;
685
+ const safeCol = Math.min(col, lists.length - 1);
686
+ const cards = data.cardsByList.get(lists[safeCol].id) ?? [];
687
+ const safeRow = cards.length === 0 ? -1 : Math.min(row, cards.length - 1);
688
+ if (key.leftArrow || input === "h") {
689
+ setCol(Math.max(0, safeCol - 1));
690
+ setRow(0);
691
+ } else if (key.rightArrow || input === "l") {
692
+ setCol(Math.min(lists.length - 1, safeCol + 1));
693
+ setRow(0);
694
+ } else if (key.upArrow || input === "k") {
695
+ if (safeRow > 0) setRow(safeRow - 1);
696
+ } else if (key.downArrow || input === "j") {
697
+ if (safeRow >= 0 && safeRow < cards.length - 1) setRow(safeRow + 1);
698
+ } else if (key.return && safeRow >= 0) {
699
+ const focusedCard = cards[safeRow];
700
+ if (focusedCard) void loadExtras(focusedCard);
701
+ setDetail(true);
702
+ }
703
+ },
704
+ { isActive: !detail },
705
+ );
516
706
 
517
707
  const columns = stdout?.columns ?? 80;
518
708
  const rows = stdout?.rows ?? 24;
@@ -568,6 +758,9 @@ function BoardView({
568
758
  defs={data.customFields}
569
759
  membersById={data.membersById}
570
760
  extras={extras.get(focusedCard.id)}
761
+ client={client}
762
+ onClose={() => setDetail(false)}
763
+ onChanged={() => void loadExtras(focusedCard, true)}
571
764
  />
572
765
  ) : (
573
766
  <Box>
@@ -588,13 +781,13 @@ function BoardView({
588
781
  {colStart + visibleCols < lists.length ? <Text dimColor>›</Text> : null}
589
782
  </Box>
590
783
  )}
591
- <Box marginTop={1}>
592
- <Text dimColor>
593
- {detail
594
- ? "esc/⏎/q back"
595
- : `←→ lists · ↑↓ cards · ⏎ detail · r refresh${onBack ? " · esc boards" : ""} · q quit`}
596
- </Text>
597
- </Box>
784
+ {detail ? null : (
785
+ <Box marginTop={1}>
786
+ <Text dimColor>
787
+ {`←→ lists · ↑↓ cards · ⏎ detail · r refresh${onBack ? " · esc boards" : ""} · q quit`}
788
+ </Text>
789
+ </Box>
790
+ )}
598
791
  </Box>
599
792
  );
600
793
  }
package/src/index.test.ts CHANGED
@@ -6,6 +6,7 @@ import { parseKvPairs } from "./cli/context.ts";
6
6
  import { customFieldChips } from "./cli/ui/custom-fields.ts";
7
7
  import { dueStatus, labelHex, listAccentHex } from "./cli/ui/palette.ts";
8
8
  import { isBoard, isCard, isLabel, isList } from "./cli/ui/shapes.ts";
9
+ import { attachmentMime } from "./util/attachment.ts";
9
10
 
10
11
  describe("parseKvPairs", () => {
11
12
  it("parses key=value pairs", () => {
@@ -39,6 +40,14 @@ describe("authLoginUrl", () => {
39
40
  });
40
41
  });
41
42
 
43
+ describe("attachmentMime", () => {
44
+ it("maps common extensions and falls back to octet-stream", () => {
45
+ assert.equal(attachmentMime("shot.PNG"), "image/png");
46
+ assert.equal(attachmentMime("doc.pdf"), "application/pdf");
47
+ assert.equal(attachmentMime("archive.zip"), "application/octet-stream");
48
+ });
49
+ });
50
+
42
51
  describe("trello HTTP helpers", () => {
43
52
  it("parses JSON responses", () => {
44
53
  assert.deepEqual(parseTrelloResponse('{"id":"1"}'), { id: "1" });
@@ -0,0 +1,29 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { basename, extname } from "node:path";
3
+
4
+ const MIME_BY_EXT: Record<string, string> = {
5
+ ".png": "image/png",
6
+ ".jpg": "image/jpeg",
7
+ ".jpeg": "image/jpeg",
8
+ ".gif": "image/gif",
9
+ ".webp": "image/webp",
10
+ ".svg": "image/svg+xml",
11
+ ".pdf": "application/pdf",
12
+ ".txt": "text/plain",
13
+ ".md": "text/markdown",
14
+ ".json": "application/json",
15
+ };
16
+
17
+ export function attachmentMime(filePath: string): string {
18
+ return MIME_BY_EXT[extname(filePath).toLowerCase()] ?? "application/octet-stream";
19
+ }
20
+
21
+ export function attachmentForm(filePath: string, name?: string): FormData {
22
+ const form = new FormData();
23
+ form.append(
24
+ "file",
25
+ new Blob([readFileSync(filePath)], { type: attachmentMime(filePath) }),
26
+ name ?? basename(filePath),
27
+ );
28
+ return form;
29
+ }