@junheep/gwt 0.2.0 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/bin/gwt.mjs +96 -47
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -214,9 +214,9 @@ Setup failures retain the worktree and record the failure. Retry with
214
214
  `gwt setup <id>` or remove it explicitly.
215
215
 
216
216
  Run `gwt switch` without a target to open the interactive picker. Use the
217
- arrow keys, `j`/`k`, or Ctrl-n/Ctrl-p to move; press 1–9 to select a numbered
218
- row immediately; or press `/` to filter by branch, ID, or path. Enter switches
219
- to the selected worktree. Escape leaves filter mode or cancels the picker.
217
+ arrow keys, `j`/`k`, or Ctrl-n/Ctrl-p to move; press `/` to filter by branch,
218
+ ID, or path. Enter switches to the selected worktree. Escape leaves filter
219
+ mode or cancels the picker.
220
220
 
221
221
  `gwt remove` refuses dirty worktrees and first tries to delete the branch with
222
222
  `git branch -d`. If Git rejects safe deletion, an interactive terminal asks
package/bin/gwt.mjs CHANGED
@@ -26,6 +26,7 @@ import { createInterface } from "node:readline/promises"
26
26
  const PROJECT_CONFIG_FILE = ".gwt.json"
27
27
  const PORT_MIN = 20_000
28
28
  const PORT_MAX = 39_999
29
+ const PICKER_ESCAPE_CODE_TIMEOUT_MS = 50
29
30
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
30
31
  const DEFAULT_CONFIG = { worktreeDirectory: ".worktrees", copyFiles: [], ports: [] }
31
32
  const SKILL_DIRECTORIES = { claude: ".claude", codex: ".agents" }
@@ -655,17 +656,35 @@ async function commandNew(args) {
655
656
  }
656
657
  }
657
658
 
658
- async function chooseWorktree(repository) {
659
- if (!process.stdin.isTTY || !process.stdout.isTTY) throw new CliError("A worktree selector is required in non-interactive mode")
659
+ function linkedWorktreeRows(repository, metadata = loadMetadata(repository)) {
660
660
  const current = currentWorktree(repository)
661
- const choices = repository.worktrees.map((worktree, index) => {
662
- const metadata = metadataForWorktree(repository, worktree)
663
- const relativePath = relative(repository.primaryPath, worktree.path)
661
+ return repository.worktrees.map((worktree, index) => {
662
+ const item = metadata.find((entry) => resolve(entry.path) === resolve(worktree.path))
664
663
  return {
665
664
  worktree,
666
- current: resolve(current?.path ?? "") === resolve(worktree.path),
665
+ current: current && resolve(current.path) === resolve(worktree.path),
666
+ id: item?.id ?? (index === 0 ? "primary" : "-"),
667
667
  branch: worktree.branch ?? "(detached)",
668
- id: metadata?.id ?? (index === 0 ? "primary" : "native"),
668
+ setup: item?.setup ?? (index === 0 ? "-" : "unmanaged"),
669
+ path: worktree.path,
670
+ }
671
+ })
672
+ }
673
+
674
+ function terminalColors() {
675
+ if (!process.stdout.isTTY || process.env.NO_COLOR !== undefined) {
676
+ return { cyan: "", yellow: "", dim: "", reset: "" }
677
+ }
678
+ return { cyan: "\x1b[36m", yellow: "\x1b[33m", dim: "\x1b[2m", reset: "\x1b[0m" }
679
+ }
680
+
681
+ async function chooseWorktree(repository) {
682
+ if (!process.stdin.isTTY || !process.stdout.isTTY) throw new CliError("A worktree selector is required in non-interactive mode")
683
+ const choices = linkedWorktreeRows(repository).map((row) => {
684
+ const { worktree } = row
685
+ const relativePath = relative(repository.primaryPath, worktree.path)
686
+ return {
687
+ ...row,
669
688
  path: relativePath === "" ? "." : relativePath.startsWith(`..${sep}`) ? worktree.path : relativePath,
670
689
  }
671
690
  })
@@ -673,12 +692,11 @@ async function chooseWorktree(repository) {
673
692
  return new Promise((resolveChoice, rejectChoice) => {
674
693
  let query = ""
675
694
  let filtering = false
676
- let selected = Math.max(0, choices.findIndex((choice) => choice.current))
695
+ const initialSelected = Math.max(0, choices.findIndex((choice) => choice.current))
696
+ let selected = initialSelected
677
697
  let renderedLines = 0
678
698
  const wasRaw = process.stdin.isRaw
679
- const colors = process.env.NO_COLOR === undefined
680
- ? { cyan: "\x1b[36m", yellow: "\x1b[33m", dim: "\x1b[2m", reset: "\x1b[0m" }
681
- : { cyan: "", yellow: "", dim: "", reset: "" }
699
+ const colors = terminalColors()
682
700
 
683
701
  const clear = () => {
684
702
  if (renderedLines > 0) process.stdout.write(`\x1b[${renderedLines}A\r\x1b[J`)
@@ -691,22 +709,21 @@ async function chooseWorktree(repository) {
691
709
  .some((value) => value.toLowerCase().includes(normalizedQuery)))
692
710
  if (selected >= filtered.length) selected = Math.max(0, filtered.length - 1)
693
711
 
694
- const terminalWidth = Math.max(40, process.stdout.columns ?? 100)
695
- const numberWidth = String(Math.max(1, filtered.length)).length
712
+ const terminalWidth = Math.max(48, process.stdout.columns ?? 100)
696
713
  const idWidth = 8
697
- const longestBranch = Math.max(12, ...filtered.map((choice) => choice.branch.length))
698
- const branchWidth = Math.min(32, longestBranch, terminalWidth - numberWidth - idWidth - 22)
699
- const pathWidth = Math.max(8, terminalWidth - numberWidth - branchWidth - idWidth - 10)
700
- const fit = (value, width) => value.length > width
701
- ? `${value.slice(0, Math.max(0, width - 1))}…`
702
- : value.padEnd(width)
714
+ const setupWidth = Math.max(5, ...filtered.map((choice) => displayWidth(choice.setup)))
715
+ const longestBranch = Math.max(12, ...filtered.map((choice) => displayWidth(choice.branch)))
716
+ const flexibleWidth = terminalWidth - idWidth - setupWidth - 10
717
+ const branchWidth = Math.min(32, longestBranch, Math.max(8, flexibleWidth - 8))
718
+ const pathWidth = Math.max(8, flexibleWidth - branchWidth)
703
719
  const visibleCount = Math.max(3, (process.stdout.rows ?? 24) - 5)
704
720
  const start = Math.max(0, Math.min(selected - Math.floor(visibleCount / 2), filtered.length - visibleCount))
705
721
  const visible = filtered.slice(start, start + visibleCount)
722
+ const escapeAction = filtering ? "clear" : "cancel"
706
723
  const lines = [
707
- `${colors.dim}${fit("Switch worktree ↑↓/jk/C-n/C-p move · 1-9 select · / filter · Enter", terminalWidth)}${colors.reset}`,
708
- fit(`Filter: ${filtering ? "/" : ""}${query}`, terminalWidth),
709
- `${colors.dim} ${fit("#", numberWidth)} ${fit("BRANCH", branchWidth)} ${fit("ID", idWidth)} ${fit("PATH", pathWidth)}${colors.reset}`,
724
+ `${colors.dim}${fitDisplay(`Switch worktree Esc ${escapeAction} · Enter select · ↑↓/jk/C-n/C-p · / filter`, terminalWidth)}${colors.reset}`,
725
+ ...(filtering ? [fitDisplay(`Filter: /${query}`, terminalWidth)] : []),
726
+ `${colors.dim} ${fitDisplay("BRANCH", branchWidth)} ${fitDisplay("ID", idWidth)} ${fitDisplay("SETUP", setupWidth)} ${fitDisplay("PATH", pathWidth)}${colors.reset}`,
710
727
  ]
711
728
 
712
729
  if (visible.length === 0) {
@@ -715,8 +732,8 @@ async function chooseWorktree(repository) {
715
732
  visible.forEach((choice, visibleIndex) => {
716
733
  const index = start + visibleIndex
717
734
  const selection = index === selected ? `${colors.cyan}>${colors.reset}` : " "
718
- const currentMarker = choice.current ? `${colors.yellow}@${colors.reset}` : " "
719
- lines.push(`${selection} ${fit(String(index + 1), numberWidth)} ${currentMarker} ${fit(choice.branch, branchWidth)} ${fit(choice.id, idWidth)} ${fit(choice.path, pathWidth)}`)
735
+ const currentMarker = choice.current ? `${colors.yellow}*${colors.reset}` : " "
736
+ lines.push(`${selection} ${currentMarker} ${fitDisplay(choice.branch, branchWidth)} ${fitDisplay(choice.id, idWidth)} ${fitDisplay(choice.setup, setupWidth)} ${fitDisplay(choice.path, pathWidth)}`)
720
737
  })
721
738
  }
722
739
 
@@ -747,7 +764,10 @@ async function chooseWorktree(repository) {
747
764
  }
748
765
  if (key.name === "escape") {
749
766
  if (filtering) {
767
+ const selectedChoice = filtered[selected]
750
768
  filtering = false
769
+ query = ""
770
+ selected = selectedChoice ? choices.indexOf(selectedChoice) : initialSelected
751
771
  render()
752
772
  } else {
753
773
  finish(new CliError("Selection cancelled"))
@@ -768,11 +788,6 @@ async function chooseWorktree(repository) {
768
788
  } else if (filtering && key.name === "backspace") {
769
789
  query = [...query].slice(0, -1).join("")
770
790
  selected = 0
771
- } else if (!filtering && /^[1-9]$/.test(text)) {
772
- const choice = filtered[Number(text) - 1]
773
- if (choice) finish(null, choice)
774
- else process.stdout.write("\x07")
775
- return
776
791
  } else if (filtering && text && !key.ctrl && !key.meta) {
777
792
  query += text.replace(/[\x00-\x1f\x7f]/g, "")
778
793
  selected = 0
@@ -780,7 +795,7 @@ async function chooseWorktree(repository) {
780
795
  render()
781
796
  }
782
797
 
783
- emitKeypressEvents(process.stdin)
798
+ emitKeypressEvents(process.stdin, { escapeCodeTimeout: PICKER_ESCAPE_CODE_TIMEOUT_MS })
784
799
  process.stdin.on("keypress", onKeypress)
785
800
  process.stdout.on("resize", render)
786
801
  process.stdin.setRawMode(true)
@@ -799,18 +814,8 @@ async function commandSwitch(args) {
799
814
  }
800
815
 
801
816
  function worktreeRows(repository) {
802
- const current = currentWorktree(repository)
803
817
  const metadata = loadMetadata(repository)
804
- const rows = repository.worktrees.map((worktree, index) => {
805
- const item = metadata.find((entry) => resolve(entry.path) === resolve(worktree.path))
806
- return {
807
- current: current && resolve(current.path) === resolve(worktree.path),
808
- id: item?.id ?? (index === 0 ? "primary" : "-"),
809
- branch: worktree.branch ?? "(detached)",
810
- setup: item?.setup ?? (index === 0 ? "-" : "unmanaged"),
811
- path: worktree.path,
812
- }
813
- })
818
+ const rows = linkedWorktreeRows(repository, metadata)
814
819
  const registeredPaths = new Set(repository.worktrees.map((worktree) => resolve(worktree.path)))
815
820
  for (const item of metadata.filter((entry) => !registeredPaths.has(resolve(entry.path)))) {
816
821
  rows.push({ current: false, id: item.id, branch: "-", setup: "stale", path: item.path })
@@ -846,18 +851,62 @@ function padDisplay(value, width) {
846
851
  return `${value}${" ".repeat(Math.max(0, width - displayWidth(value)))}`
847
852
  }
848
853
 
854
+ function fitDisplay(value, width) {
855
+ const normalized = value.normalize("NFC")
856
+ if (displayWidth(normalized) <= width) return padDisplay(normalized, width)
857
+
858
+ const contentWidth = width - displayWidth("…")
859
+ let fitted = ""
860
+ let fittedWidth = 0
861
+ for (const character of normalized) {
862
+ const characterWidth = displayWidth(character)
863
+ if (fittedWidth + characterWidth > contentWidth) break
864
+ fitted += character
865
+ fittedWidth += characterWidth
866
+ }
867
+ return padDisplay(`${fitted}…`, width)
868
+ }
869
+
870
+ function truncateDisplayTail(value, width) {
871
+ const normalized = value.normalize("NFC")
872
+ if (displayWidth(normalized) <= width) return normalized
873
+
874
+ const contentWidth = width - displayWidth("…")
875
+ let fitted = ""
876
+ let fittedWidth = 0
877
+ for (const character of [...normalized].reverse()) {
878
+ const characterWidth = displayWidth(character)
879
+ if (fittedWidth + characterWidth > contentWidth) break
880
+ fitted = `${character}${fitted}`
881
+ fittedWidth += characterWidth
882
+ }
883
+ return `…${fitted}`
884
+ }
885
+
849
886
  function commandList(args) {
850
887
  if (args.length > 0) throw new CliError("Usage: gwt list")
851
888
  const repository = discoverRepository()
852
889
  const rows = worktreeRows(repository)
890
+ const colors = terminalColors()
853
891
  const widths = {
854
- id: Math.max(2, ...rows.map((row) => displayWidth(row.id))),
855
892
  branch: Math.max(6, ...rows.map((row) => displayWidth(row.branch))),
893
+ id: Math.max(2, ...rows.map((row) => displayWidth(row.id))),
856
894
  setup: Math.max(5, ...rows.map((row) => displayWidth(row.setup))),
857
895
  }
858
- console.log(` ${padDisplay("ID", widths.id)} ${padDisplay("BRANCH", widths.branch)} ${padDisplay("SETUP", widths.setup)} PATH`)
896
+ let pathWidth = null
897
+ if (process.stdout.isTTY) {
898
+ const minimumWidth = 8 + 6 + widths.id + widths.setup + 4
899
+ const terminalWidth = Math.max(minimumWidth, process.stdout.columns || 100)
900
+ const flexibleWidth = terminalWidth - widths.id - widths.setup - 8
901
+ widths.branch = Math.min(32, widths.branch, Math.max(6, flexibleWidth - 12))
902
+ pathWidth = flexibleWidth - widths.branch
903
+ }
904
+
905
+ console.log(`${colors.dim} ${fitDisplay("BRANCH", widths.branch)} ${fitDisplay("ID", widths.id)} ${fitDisplay("SETUP", widths.setup)} PATH${colors.reset}`)
859
906
  for (const row of rows) {
860
- console.log(`${row.current ? "*" : " "} ${padDisplay(row.id, widths.id)} ${padDisplay(row.branch, widths.branch)} ${padDisplay(row.setup, widths.setup)} ${row.path}`)
907
+ const currentMarker = row.current ? `${colors.yellow}*${colors.reset}` : " "
908
+ const path = pathWidth === null ? row.path : truncateDisplayTail(row.path, pathWidth)
909
+ console.log(`${currentMarker} ${fitDisplay(row.branch, widths.branch)} ${fitDisplay(row.id, widths.id)} ${fitDisplay(row.setup, widths.setup)} ${path}`)
861
910
  }
862
911
  }
863
912
 
@@ -1417,9 +1466,9 @@ Options:
1417
1466
  -h, --help Show help for this command.
1418
1467
 
1419
1468
  Behavior:
1420
- The picker supports arrow keys, j/k, Ctrl-n/Ctrl-p, number shortcuts, and
1421
- '/' filtering. Shell integration must be installed for gwt to change the
1422
- parent shell's directory; otherwise the selected path is only printed.
1469
+ The picker supports arrow keys, j/k, Ctrl-n/Ctrl-p, and '/' filtering. Shell
1470
+ integration must be installed for gwt to change the parent shell's directory;
1471
+ otherwise the selected path is only printed.
1423
1472
 
1424
1473
  Examples:
1425
1474
  gwt switch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junheep/gwt",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Lightweight native Git worktree workflows",
5
5
  "license": "MIT",
6
6
  "author": "Junhee Park",