unity-hub-cli 0.1.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 (3) hide show
  1. package/README.md +4 -2
  2. package/dist/index.js +177 -47
  3. package/package.json +5 -1
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # UnityHub CLI
2
2
 
3
- A CLI tool that reads Unity Hub's `projects-v1.json`, displays projects in an Ink-based TUI, allows navigation with arrow keys/`j`/`k`, and launches Unity Editor by pressing Enter.
3
+ [日本語版はこちら / Japanese version](README_ja.md)
4
+
5
+ A CLI tool that displays the same content as Unity Hub in an Ink-based TUI, allows navigation with arrow keys/`j`/`k`, and launches Unity Editor by pressing `o`.
4
6
 
5
7
  ## Requirements
6
8
 
@@ -83,7 +85,7 @@ npm audit signatures
83
85
  ## Controls
84
86
 
85
87
  - Arrow keys / `j` / `k`: Navigate selection
86
- - `o`: Launch selected project in Unity
88
+ - `o`: Launch selected project in Unity
87
89
  - Ctrl + C (twice): Exit
88
90
 
89
91
  The display includes Git branch (if present), Unity version, project path, and last modified time (`lastModified`).
package/dist/index.js CHANGED
@@ -443,6 +443,7 @@ var UnityLockChecker = class {
443
443
  };
444
444
 
445
445
  // src/presentation/App.tsx
446
+ import clipboard from "clipboardy";
446
447
  import { Box, Text, useApp, useInput, useStdout } from "ink";
447
448
  import { useCallback, useEffect, useMemo, useState } from "react";
448
449
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -458,13 +459,13 @@ var extractRootFolder = (repository) => {
458
459
  };
459
460
  var formatProjectName = (project, repository, useGitRootName) => {
460
461
  if (!useGitRootName) {
461
- return `${project.title} (${project.version.value})`;
462
+ return project.title;
462
463
  }
463
464
  const rootFolder = extractRootFolder(repository);
464
465
  if (!rootFolder) {
465
- return `${project.title} (${project.version.value})`;
466
+ return project.title;
466
467
  }
467
- return `${rootFolder} (${project.version.value})`;
468
+ return rootFolder;
468
469
  };
469
470
  var formatBranch = (branch) => {
470
471
  if (!branch) {
@@ -475,10 +476,61 @@ var formatBranch = (branch) => {
475
476
  }
476
477
  return `detached@${branch.sha}`;
477
478
  };
479
+ var formatRelativeTime = (lastModified) => {
480
+ if (!lastModified) {
481
+ return void 0;
482
+ }
483
+ const now = /* @__PURE__ */ new Date();
484
+ const diffMillis = now.getTime() - lastModified.getTime();
485
+ if (Number.isNaN(diffMillis)) {
486
+ return void 0;
487
+ }
488
+ const safeMillis = Math.max(0, diffMillis);
489
+ const secondsPerMinute = 60;
490
+ const secondsPerHour = secondsPerMinute * 60;
491
+ const secondsPerDay = secondsPerHour * 24;
492
+ const secondsPerMonth = secondsPerDay * 30;
493
+ const secondsPerYear = secondsPerDay * 365;
494
+ const totalSeconds = Math.floor(safeMillis / 1e3);
495
+ if (totalSeconds < 45) {
496
+ return "a few seconds ago";
497
+ }
498
+ if (totalSeconds < 45 * secondsPerMinute) {
499
+ const minutes = Math.max(1, Math.floor(totalSeconds / secondsPerMinute));
500
+ const suffix2 = minutes === 1 ? "minute" : "minutes";
501
+ return `${minutes} ${suffix2} ago`;
502
+ }
503
+ if (totalSeconds < 30 * secondsPerHour) {
504
+ const hours = Math.max(1, Math.floor(totalSeconds / secondsPerHour));
505
+ const suffix2 = hours === 1 ? "hour" : "hours";
506
+ return `${hours} ${suffix2} ago`;
507
+ }
508
+ if (totalSeconds < secondsPerMonth) {
509
+ const days = Math.max(1, Math.floor(totalSeconds / secondsPerDay));
510
+ const suffix2 = days === 1 ? "day" : "days";
511
+ return `${days} ${suffix2} ago`;
512
+ }
513
+ if (totalSeconds < secondsPerYear) {
514
+ const months = Math.max(1, Math.floor(totalSeconds / secondsPerMonth));
515
+ const suffix2 = months === 1 ? "month" : "months";
516
+ return `${months} ${suffix2} ago`;
517
+ }
518
+ const years = Math.max(1, Math.floor(totalSeconds / secondsPerYear));
519
+ const suffix = years === 1 ? "year" : "years";
520
+ return `${years} ${suffix} ago`;
521
+ };
522
+ var UPDATED_LABEL = "Last:";
523
+ var formatUpdatedText = (lastModified) => {
524
+ const relativeTime = formatRelativeTime(lastModified);
525
+ if (!relativeTime) {
526
+ return void 0;
527
+ }
528
+ return `${UPDATED_LABEL} ${relativeTime}`;
529
+ };
478
530
  var homeDirectory = process.env.HOME ?? "";
479
531
  var homePrefix = homeDirectory ? `${homeDirectory}/` : "";
480
532
  var minimumVisibleProjectCount = 4;
481
- var defaultHintMessage = "Move with arrows or j/k \xB7 Launch with o \xB7 Exit with Ctrl+C twice";
533
+ var defaultHintMessage = "Move with arrows or j/k \xB7 Launch with o \xB7 Copy cd path with c \xB7 Exit with Ctrl+C";
482
534
  var PROJECT_COLOR = "#abd8e7";
483
535
  var BRANCH_COLOR = "#e3839c";
484
536
  var PATH_COLOR = "#719bd8";
@@ -494,6 +546,10 @@ var shortenHomePath = (targetPath) => {
494
546
  }
495
547
  return targetPath;
496
548
  };
549
+ var buildCdCommand = (targetPath) => {
550
+ const escapedPath = targetPath.replaceAll('"', '\\"');
551
+ return `cd "${escapedPath}"`;
552
+ };
497
553
  var App = ({
498
554
  projects,
499
555
  onLaunch,
@@ -505,8 +561,8 @@ var App = ({
505
561
  const { stdout } = useStdout();
506
562
  const [visibleCount, setVisibleCount] = useState(minimumVisibleProjectCount);
507
563
  const [index, setIndex] = useState(0);
508
- const [hint, setHint] = useState("Move with j/k \xB7 Launch with o \xB7 Exit with Ctrl+C twice");
509
- const [pendingExit, setPendingExit] = useState(false);
564
+ const [hint, setHint] = useState(defaultHintMessage);
565
+ const [windowStart, setWindowStart] = useState(0);
510
566
  const linesPerProject = (showBranch ? 1 : 0) + (showPath ? 1 : 0) + 2;
511
567
  const sortedProjects = useMemo(() => {
512
568
  const fallbackTime = 0;
@@ -539,29 +595,23 @@ var App = ({
539
595
  }, [projects, useGitRootName]);
540
596
  useEffect(() => {
541
597
  const handleSigint = () => {
542
- if (!pendingExit) {
543
- setPendingExit(true);
544
- setHint("Press Ctrl+C again to exit");
545
- setTimeout(() => {
546
- setPendingExit(false);
547
- setHint(defaultHintMessage);
548
- }, 2e3);
549
- return;
550
- }
551
598
  exit();
552
599
  };
553
600
  process.on("SIGINT", handleSigint);
554
601
  return () => {
555
602
  process.off("SIGINT", handleSigint);
556
603
  };
557
- }, [exit, pendingExit]);
604
+ }, [exit]);
558
605
  useEffect(() => {
559
606
  const updateVisibleCount = () => {
560
607
  if (!stdout || typeof stdout.columns !== "number" || typeof stdout.rows !== "number") {
561
608
  setVisibleCount(minimumVisibleProjectCount);
562
609
  return;
563
610
  }
564
- const reservedRows = 6;
611
+ const borderRows = 2;
612
+ const hintRows = 1;
613
+ const hintMarginRows = 1;
614
+ const reservedRows = borderRows + hintRows + hintMarginRows;
565
615
  const availableRows = stdout.rows - reservedRows;
566
616
  const rowsPerProject = Math.max(linesPerProject, 1);
567
617
  const calculatedCount = Math.max(
@@ -576,6 +626,7 @@ var App = ({
576
626
  stdout?.off("resize", updateVisibleCount);
577
627
  };
578
628
  }, [linesPerProject, stdout]);
629
+ const limit = Math.max(minimumVisibleProjectCount, visibleCount);
579
630
  const move = useCallback(
580
631
  (delta) => {
581
632
  setIndex((prev) => {
@@ -591,20 +642,104 @@ var App = ({
591
642
  }
592
643
  return next;
593
644
  });
645
+ setWindowStart((prevStart) => {
646
+ if (sortedProjects.length <= limit) {
647
+ return 0;
648
+ }
649
+ const maxStart = Math.max(0, sortedProjects.length - limit);
650
+ let nextStart = prevStart;
651
+ if (delta > 0) {
652
+ const nextIndex = Math.min(sortedProjects.length - 1, index + delta);
653
+ if (nextIndex >= prevStart + limit) {
654
+ nextStart = nextIndex - limit + 1;
655
+ }
656
+ } else if (delta < 0) {
657
+ const nextIndex = Math.max(0, index + delta);
658
+ if (nextIndex < prevStart) {
659
+ nextStart = nextIndex;
660
+ }
661
+ }
662
+ if (nextStart < 0) {
663
+ nextStart = 0;
664
+ }
665
+ if (nextStart > maxStart) {
666
+ nextStart = maxStart;
667
+ }
668
+ return nextStart;
669
+ });
594
670
  },
595
- [sortedProjects.length]
671
+ [index, limit, sortedProjects.length]
596
672
  );
673
+ useEffect(() => {
674
+ setWindowStart((prevStart) => {
675
+ if (sortedProjects.length <= limit) {
676
+ return prevStart === 0 ? prevStart : 0;
677
+ }
678
+ const maxStart = Math.max(0, sortedProjects.length - limit);
679
+ let nextStart = prevStart;
680
+ if (index < prevStart) {
681
+ nextStart = index;
682
+ } else if (index >= prevStart + limit) {
683
+ nextStart = index - limit + 1;
684
+ }
685
+ if (nextStart < 0) {
686
+ nextStart = 0;
687
+ }
688
+ if (nextStart > maxStart) {
689
+ nextStart = maxStart;
690
+ }
691
+ return nextStart;
692
+ });
693
+ }, [index, limit, sortedProjects.length]);
694
+ const copyProjectPath = useCallback(() => {
695
+ const projectPath = sortedProjects[index]?.project.path;
696
+ if (!projectPath) {
697
+ setHint("No project to copy");
698
+ setTimeout(() => {
699
+ setHint(defaultHintMessage);
700
+ }, 2e3);
701
+ return;
702
+ }
703
+ try {
704
+ const command = buildCdCommand(projectPath);
705
+ clipboard.writeSync(command);
706
+ const displayPath = shortenHomePath(projectPath);
707
+ setHint(`Copied command: cd "${displayPath}"`);
708
+ } catch (error) {
709
+ const message = error instanceof Error ? error.message : String(error);
710
+ setHint(`Failed to copy: ${message}`);
711
+ }
712
+ setTimeout(() => {
713
+ setHint(defaultHintMessage);
714
+ }, 2e3);
715
+ }, [index, sortedProjects]);
597
716
  const launchSelected = useCallback(async () => {
598
- const project = sortedProjects[index]?.project;
599
- if (!project) {
717
+ const projectView = sortedProjects[index];
718
+ if (!projectView) {
719
+ setHint("No project to launch");
720
+ setTimeout(() => {
721
+ setHint(defaultHintMessage);
722
+ }, 2e3);
723
+ return;
724
+ }
725
+ const { project } = projectView;
726
+ try {
727
+ const command = buildCdCommand(project.path);
728
+ clipboard.writeSync(command);
729
+ } catch (error) {
730
+ const message = error instanceof Error ? error.message : String(error);
731
+ setHint(`Failed to copy: ${message}`);
732
+ setTimeout(() => {
733
+ setHint(defaultHintMessage);
734
+ }, 3e3);
600
735
  return;
601
736
  }
602
737
  try {
603
738
  await onLaunch(project);
604
- setHint(`Launched Unity: ${project.title}`);
739
+ setHint(`Launched: ${project.title}`);
605
740
  setTimeout(() => {
606
741
  setHint(defaultHintMessage);
607
- }, 2e3);
742
+ }, 3e3);
608
743
  } catch (error) {
609
744
  if (error instanceof LaunchCancelledError) {
610
745
  setHint("Launch cancelled");
@@ -630,32 +765,25 @@ var App = ({
630
765
  if (input === "o") {
631
766
  void launchSelected();
632
767
  }
768
+ if (input === "c") {
769
+ copyProjectPath();
770
+ }
633
771
  });
634
772
  const { startIndex, visibleProjects } = useMemo(() => {
635
- const limit = Math.max(minimumVisibleProjectCount, visibleCount);
636
773
  if (sortedProjects.length <= limit) {
637
774
  return {
638
775
  startIndex: 0,
639
- endIndex: sortedProjects.length,
640
776
  visibleProjects: sortedProjects
641
777
  };
642
778
  }
643
- const halfWindow = Math.floor(limit / 2);
644
- let start = index - halfWindow;
645
- let end = index + halfWindow + limit % 2;
646
- if (start < 0) {
647
- start = 0;
648
- end = limit;
649
- }
650
- if (end > sortedProjects.length) {
651
- end = sortedProjects.length;
652
- start = Math.max(0, end - limit);
653
- }
779
+ const maxStart = Math.max(0, sortedProjects.length - limit);
780
+ const clampedStart = Math.min(Math.max(0, windowStart), maxStart);
781
+ const end = Math.min(clampedStart + limit, sortedProjects.length);
654
782
  return {
655
- startIndex: start,
656
- visibleProjects: sortedProjects.slice(start, end)
783
+ startIndex: clampedStart,
784
+ visibleProjects: sortedProjects.slice(clampedStart, end)
657
785
  };
658
- }, [index, sortedProjects, visibleCount]);
786
+ }, [limit, sortedProjects, windowStart]);
659
787
  const scrollbarChars = useMemo(() => {
660
788
  const totalProjects = projects.length;
661
789
  const totalLines = totalProjects * linesPerProject;
@@ -688,7 +816,9 @@ var App = ({
688
816
  const rowIndex = startIndex + offset;
689
817
  const isSelected = rowIndex === index;
690
818
  const arrow = isSelected ? ">" : " ";
691
- const titleLine = formatProjectName(project, repository, useGitRootName);
819
+ const projectName = formatProjectName(project, repository, useGitRootName);
820
+ const versionLabel = `(${project.version.value})`;
821
+ const updatedText = formatUpdatedText(project.lastModified);
692
822
  const pathLine = shortenHomePath(project.path);
693
823
  const branchLine = formatBranch(repository?.branch);
694
824
  const baseScrollbarIndex = offset * linesPerProject;
@@ -696,21 +826,19 @@ var App = ({
696
826
  const branchScrollbar = showBranch ? scrollbarChars[baseScrollbarIndex + 1] ?? " " : " ";
697
827
  const pathScrollbar = showPath ? scrollbarChars[baseScrollbarIndex + 1 + (showBranch ? 1 : 0)] ?? " " : " ";
698
828
  const spacerScrollbar = scrollbarChars[baseScrollbarIndex + linesPerProject - 1] ?? " ";
699
- const versionStart = titleLine.indexOf("(");
700
- const versionText = versionStart >= 0 ? titleLine.slice(versionStart) : "";
701
- const nameText = versionStart >= 0 ? titleLine.slice(0, versionStart).trimEnd() : titleLine;
702
829
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "row", children: [
703
830
  /* @__PURE__ */ jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [
704
831
  /* @__PURE__ */ jsxs(Text, { children: [
705
832
  /* @__PURE__ */ jsxs(Text, { color: isSelected ? "green" : PROJECT_COLOR, bold: true, children: [
706
833
  arrow,
707
834
  " ",
708
- nameText
835
+ projectName
709
836
  ] }),
710
- versionText ? /* @__PURE__ */ jsxs(Text, { color: isSelected ? "green" : void 0, children: [
837
+ /* @__PURE__ */ jsxs(Text, { color: isSelected ? "green" : void 0, children: [
711
838
  " ",
712
- versionText
713
- ] }) : null
839
+ versionLabel
840
+ ] }),
841
+ updatedText ? /* @__PURE__ */ jsx(Text, { color: isSelected ? "green" : void 0, children: ` ${updatedText}` }) : null
714
842
  ] }),
715
843
  showBranch ? /* @__PURE__ */ jsxs(Text, { color: isSelected ? "green" : BRANCH_COLOR, children: [
716
844
  " ",
@@ -762,7 +890,7 @@ var bootstrap = async () => {
762
890
  const showPath = !process2.argv.includes("--hide-path");
763
891
  try {
764
892
  const projects = await listProjectsUseCase.execute();
765
- render(
893
+ const { waitUntilExit } = render(
766
894
  /* @__PURE__ */ jsx2(
767
895
  App,
768
896
  {
@@ -774,6 +902,8 @@ var bootstrap = async () => {
774
902
  }
775
903
  )
776
904
  );
905
+ await waitUntilExit();
906
+ process2.stdout.write("\x1B[2J\x1B[3J\x1B[H");
777
907
  } catch (error) {
778
908
  const message = error instanceof Error ? error.message : String(error);
779
909
  console.error(message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unity-hub-cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "A CLI tool that reads Unity Hub's projects and launches Unity Editor with an interactive TUI",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -36,9 +36,13 @@
36
36
  "dist"
37
37
  ],
38
38
  "dependencies": {
39
+ "clipboardy": "^4.0.0",
39
40
  "ink": "^4.4.1",
40
41
  "react": "^18.3.1"
41
42
  },
43
+ "engines": {
44
+ "node": ">=18"
45
+ },
42
46
  "devDependencies": {
43
47
  "@types/node": "^20.19.20",
44
48
  "@types/react": "^18.3.26",