stage-tui 1.0.7
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/README.md +112 -0
- package/bin/stage +51 -0
- package/index.ts +22 -0
- package/package.json +46 -0
- package/src/ai-commit.ts +706 -0
- package/src/app.tsx +127 -0
- package/src/config-file.ts +40 -0
- package/src/config.ts +283 -0
- package/src/git-branch-name.ts +13 -0
- package/src/git-process.ts +25 -0
- package/src/git-status-parser.ts +103 -0
- package/src/git.ts +298 -0
- package/src/hooks/use-branch-dialog-controller.ts +188 -0
- package/src/hooks/use-commit-history-controller.ts +130 -0
- package/src/hooks/use-git-tui-controller.ts +310 -0
- package/src/hooks/use-git-tui-effects.ts +168 -0
- package/src/hooks/use-git-tui-keyboard.ts +293 -0
- package/src/ui/components/branch-dialog.tsx +107 -0
- package/src/ui/components/commit-dialog.tsx +68 -0
- package/src/ui/components/commit-history-dialog.tsx +87 -0
- package/src/ui/components/diff-workspace.tsx +108 -0
- package/src/ui/components/footer-bar.tsx +65 -0
- package/src/ui/components/shortcuts-dialog.tsx +53 -0
- package/src/ui/components/top-bar.tsx +36 -0
- package/src/ui/diff-style.ts +33 -0
- package/src/ui/theme.ts +151 -0
- package/src/ui/types.ts +21 -0
- package/src/ui/utils.ts +99 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useEffect, useState } from "react"
|
|
2
|
+
|
|
3
|
+
import { fitFooterLine } from "../utils"
|
|
4
|
+
import type { UiTheme } from "../theme"
|
|
5
|
+
|
|
6
|
+
type FooterBarProps = {
|
|
7
|
+
statusMessage: string
|
|
8
|
+
showShortcutsHint: boolean
|
|
9
|
+
terminalWidth: number
|
|
10
|
+
fatalError: string | null
|
|
11
|
+
isBusy: boolean
|
|
12
|
+
theme: UiTheme
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SPINNER_FRAMES = "⣾⣽⣻⢿⡿⣟⣯⣷"
|
|
16
|
+
|
|
17
|
+
export function FooterBar({ statusMessage, showShortcutsHint, terminalWidth, fatalError, isBusy, theme }: FooterBarProps) {
|
|
18
|
+
const [spinnerIndex, setSpinnerIndex] = useState(0)
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!isBusy) {
|
|
22
|
+
setSpinnerIndex(0)
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const timer = setInterval(() => {
|
|
27
|
+
setSpinnerIndex((current) => (current + 1) % SPINNER_FRAMES.length)
|
|
28
|
+
}, 90)
|
|
29
|
+
|
|
30
|
+
return () => clearInterval(timer)
|
|
31
|
+
}, [isBusy])
|
|
32
|
+
|
|
33
|
+
const busyPrefix = isBusy ? `${SPINNER_FRAMES[spinnerIndex]} ` : ""
|
|
34
|
+
const statusWithSpinner = `${busyPrefix}${statusMessage}`
|
|
35
|
+
const footerInnerWidth = Math.max(terminalWidth - 2, 0)
|
|
36
|
+
const shortcutsHint = showShortcutsHint ? "[?] shortcuts" : ""
|
|
37
|
+
|
|
38
|
+
if (!shortcutsHint) {
|
|
39
|
+
const footerLine = fitFooterLine(statusWithSpinner, footerInnerWidth)
|
|
40
|
+
return (
|
|
41
|
+
<box style={{ height: 1, paddingLeft: 1, paddingRight: 1 }}>
|
|
42
|
+
<text fg={fatalError ? theme.colors.footerError : isBusy ? theme.colors.footerBusy : theme.colors.footerReady}>{footerLine}</text>
|
|
43
|
+
</box>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (shortcutsHint.length >= footerInnerWidth) {
|
|
48
|
+
const hintOnlyLine = shortcutsHint.slice(0, footerInnerWidth)
|
|
49
|
+
return (
|
|
50
|
+
<box style={{ height: 1, paddingLeft: 1, paddingRight: 1 }}>
|
|
51
|
+
<text fg={theme.colors.subtleText}>{hintOnlyLine}</text>
|
|
52
|
+
</box>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const leftWidth = Math.max(footerInnerWidth - shortcutsHint.length - 1, 0)
|
|
57
|
+
const leftLine = fitFooterLine(statusWithSpinner, leftWidth)
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<box style={{ height: 1, paddingLeft: 1, paddingRight: 1, flexDirection: "row" }}>
|
|
61
|
+
<text fg={fatalError ? theme.colors.footerError : isBusy ? theme.colors.footerBusy : theme.colors.footerReady}>{leftLine}</text>
|
|
62
|
+
<text fg={theme.colors.subtleText}> {shortcutsHint}</text>
|
|
63
|
+
</box>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { UiTheme } from "../theme"
|
|
2
|
+
|
|
3
|
+
type ShortcutsDialogProps = {
|
|
4
|
+
open: boolean
|
|
5
|
+
aiCommitEnabled: boolean
|
|
6
|
+
theme: UiTheme
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const BASE_SHORTCUT_ROWS: ReadonlyArray<readonly [string, string]> = [
|
|
10
|
+
["?", "show/hide shortcuts"],
|
|
11
|
+
["b", "⎇ change branch"],
|
|
12
|
+
["h", "◷ open commit history"],
|
|
13
|
+
["space", "include/exclude file in commit"],
|
|
14
|
+
["↑ / ↓", "move file selection"],
|
|
15
|
+
["r", "↻ refresh"],
|
|
16
|
+
["f", "⇣ fetch"],
|
|
17
|
+
["l", "⇩ pull"],
|
|
18
|
+
["p", "⇧ push"],
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
export function ShortcutsDialog({ open, aiCommitEnabled, theme }: ShortcutsDialogProps) {
|
|
22
|
+
if (!open) return null
|
|
23
|
+
const commitRow: readonly [string, string] = aiCommitEnabled
|
|
24
|
+
? ["c", "✦ generate AI commit"]
|
|
25
|
+
: ["c", "✓ open commit dialog"]
|
|
26
|
+
const rows = [...BASE_SHORTCUT_ROWS, commitRow, ["esc", "close dialog or exit"]] as const
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<box
|
|
30
|
+
style={{
|
|
31
|
+
width: "100%",
|
|
32
|
+
flexGrow: 1,
|
|
33
|
+
paddingLeft: 6,
|
|
34
|
+
paddingRight: 6,
|
|
35
|
+
paddingTop: 8,
|
|
36
|
+
paddingBottom: 4,
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
<box style={{ width: "100%", maxWidth: 72, flexDirection: "column", gap: 1 }}>
|
|
40
|
+
<text fg={theme.colors.title}>shortcuts</text>
|
|
41
|
+
<text fg={theme.colors.subtleText}>press ? or esc to close</text>
|
|
42
|
+
<box style={{ flexDirection: "column", marginTop: 1 }}>
|
|
43
|
+
{rows.map(([key, description]) => (
|
|
44
|
+
<box key={key} style={{ flexDirection: "row", gap: 2 }}>
|
|
45
|
+
<text fg={theme.colors.hintText}>{key.padEnd(8, " ")}</text>
|
|
46
|
+
<text fg={theme.colors.text}>{description}</text>
|
|
47
|
+
</box>
|
|
48
|
+
))}
|
|
49
|
+
</box>
|
|
50
|
+
</box>
|
|
51
|
+
</box>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { UiTheme } from "../theme"
|
|
2
|
+
|
|
3
|
+
type TopBarProps = {
|
|
4
|
+
currentBranch: string
|
|
5
|
+
tracking: {
|
|
6
|
+
loading: boolean
|
|
7
|
+
upstream: string | null
|
|
8
|
+
ahead: number
|
|
9
|
+
behind: number
|
|
10
|
+
}
|
|
11
|
+
theme: UiTheme
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function TopBar({ currentBranch, tracking, theme }: TopBarProps) {
|
|
15
|
+
return (
|
|
16
|
+
<box
|
|
17
|
+
style={{ height: 3, flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingLeft: 1, paddingRight: 1 }}
|
|
18
|
+
>
|
|
19
|
+
<box style={{ flexDirection: "row", gap: 1 }}>
|
|
20
|
+
<text fg={theme.colors.mutedText}>⎇</text>
|
|
21
|
+
<text fg={theme.colors.text}>{currentBranch}</text>
|
|
22
|
+
</box>
|
|
23
|
+
{tracking.loading ? (
|
|
24
|
+
<text fg={theme.colors.subtleText}>… loading repository state</text>
|
|
25
|
+
) : (
|
|
26
|
+
<box style={{ flexDirection: "row" }}>
|
|
27
|
+
{tracking.upstream ? <text fg={theme.colors.subtleText}>{tracking.upstream}</text> : null}
|
|
28
|
+
{!tracking.upstream ? <text fg={theme.colors.subtleText}>◌ unpublished</text> : null}
|
|
29
|
+
{tracking.upstream && tracking.ahead === 0 && tracking.behind === 0 ? <text fg={theme.colors.subtleText}> ✓ synced</text> : null}
|
|
30
|
+
{tracking.ahead > 0 ? <text fg={theme.colors.successText}> ↑{tracking.ahead}</text> : null}
|
|
31
|
+
{tracking.behind > 0 ? <text fg={theme.colors.warningText}> ↓{tracking.behind}</text> : null}
|
|
32
|
+
</box>
|
|
33
|
+
)}
|
|
34
|
+
</box>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { RGBA, SyntaxStyle } from "@opentui/core"
|
|
2
|
+
|
|
3
|
+
type DiffStyleMode = "dark" | "light"
|
|
4
|
+
|
|
5
|
+
const DARK_DIFF_SYNTAX_STYLE = SyntaxStyle.fromStyles({
|
|
6
|
+
keyword: { fg: RGBA.fromHex("#c792ea"), italic: true },
|
|
7
|
+
string: { fg: RGBA.fromHex("#c3e88d") },
|
|
8
|
+
comment: { fg: RGBA.fromHex("#6a737d"), italic: true },
|
|
9
|
+
number: { fg: RGBA.fromHex("#f78c6c") },
|
|
10
|
+
function: { fg: RGBA.fromHex("#82aaff") },
|
|
11
|
+
type: { fg: RGBA.fromHex("#ffcb6b") },
|
|
12
|
+
variable: { fg: RGBA.fromHex("#f07178") },
|
|
13
|
+
operator: { fg: RGBA.fromHex("#89ddff") },
|
|
14
|
+
punctuation: { fg: RGBA.fromHex("#cdd6f4") },
|
|
15
|
+
default: { fg: RGBA.fromHex("#e6edf3") },
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const LIGHT_DIFF_SYNTAX_STYLE = SyntaxStyle.fromStyles({
|
|
19
|
+
keyword: { fg: RGBA.fromHex("#7c3aed"), italic: true },
|
|
20
|
+
string: { fg: RGBA.fromHex("#15803d") },
|
|
21
|
+
comment: { fg: RGBA.fromHex("#6b7280"), italic: true },
|
|
22
|
+
number: { fg: RGBA.fromHex("#c2410c") },
|
|
23
|
+
function: { fg: RGBA.fromHex("#1d4ed8") },
|
|
24
|
+
type: { fg: RGBA.fromHex("#a16207") },
|
|
25
|
+
variable: { fg: RGBA.fromHex("#be123c") },
|
|
26
|
+
operator: { fg: RGBA.fromHex("#0e7490") },
|
|
27
|
+
punctuation: { fg: RGBA.fromHex("#334155") },
|
|
28
|
+
default: { fg: RGBA.fromHex("#111827") },
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export function createDiffSyntaxStyle(mode: DiffStyleMode): SyntaxStyle {
|
|
32
|
+
return mode === "light" ? LIGHT_DIFF_SYNTAX_STYLE : DARK_DIFF_SYNTAX_STYLE
|
|
33
|
+
}
|
package/src/ui/theme.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { SyntaxStyle } from "@opentui/core"
|
|
2
|
+
import { execFileSync } from "node:child_process"
|
|
3
|
+
|
|
4
|
+
import { createDiffSyntaxStyle } from "./diff-style"
|
|
5
|
+
|
|
6
|
+
export type UiThemeMode = "dark" | "light"
|
|
7
|
+
export type UiThemePreference = UiThemeMode | "auto"
|
|
8
|
+
|
|
9
|
+
type UiColors = {
|
|
10
|
+
title: string
|
|
11
|
+
text: string
|
|
12
|
+
mutedText: string
|
|
13
|
+
subtleText: string
|
|
14
|
+
hintText: string
|
|
15
|
+
successText: string
|
|
16
|
+
warningText: string
|
|
17
|
+
selectedRowBackground: string
|
|
18
|
+
selectText: string
|
|
19
|
+
selectSelectedBackground: string
|
|
20
|
+
selectSelectedText: string
|
|
21
|
+
selectFocusedText: string
|
|
22
|
+
inputText: string
|
|
23
|
+
inputFocusedText: string
|
|
24
|
+
secondaryInputText: string
|
|
25
|
+
footerReady: string
|
|
26
|
+
footerBusy: string
|
|
27
|
+
footerError: string
|
|
28
|
+
diffForeground: string
|
|
29
|
+
diffLineNumber: string
|
|
30
|
+
diffAddedBackground: string
|
|
31
|
+
diffRemovedBackground: string
|
|
32
|
+
diffAddedLineNumberBackground: string
|
|
33
|
+
diffRemovedLineNumberBackground: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type UiTheme = {
|
|
37
|
+
mode: UiThemeMode
|
|
38
|
+
colors: UiColors
|
|
39
|
+
diffSyntaxStyle: SyntaxStyle
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DARK_COLORS: UiColors = {
|
|
43
|
+
title: "#f5f5f5",
|
|
44
|
+
text: "#f3f4f6",
|
|
45
|
+
mutedText: "#737373",
|
|
46
|
+
subtleText: "#525252",
|
|
47
|
+
hintText: "#9ca3af",
|
|
48
|
+
successText: "#3fb950",
|
|
49
|
+
warningText: "#d29922",
|
|
50
|
+
selectedRowBackground: "#101010",
|
|
51
|
+
selectText: "#9ca3af",
|
|
52
|
+
selectSelectedBackground: "#111111",
|
|
53
|
+
selectSelectedText: "#ffffff",
|
|
54
|
+
selectFocusedText: "#f3f4f6",
|
|
55
|
+
inputText: "#f3f4f6",
|
|
56
|
+
inputFocusedText: "#f9fafb",
|
|
57
|
+
secondaryInputText: "#d1d5db",
|
|
58
|
+
footerReady: "#58a6ff",
|
|
59
|
+
footerBusy: "#d29922",
|
|
60
|
+
footerError: "#ff7b72",
|
|
61
|
+
diffForeground: "#e5e7eb",
|
|
62
|
+
diffLineNumber: "#525252",
|
|
63
|
+
diffAddedBackground: "#06180c",
|
|
64
|
+
diffRemovedBackground: "#220909",
|
|
65
|
+
diffAddedLineNumberBackground: "#0a2212",
|
|
66
|
+
diffRemovedLineNumberBackground: "#2c1010",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const LIGHT_COLORS: UiColors = {
|
|
70
|
+
title: "#111827",
|
|
71
|
+
text: "#1f2937",
|
|
72
|
+
mutedText: "#6b7280",
|
|
73
|
+
subtleText: "#9ca3af",
|
|
74
|
+
hintText: "#64748b",
|
|
75
|
+
successText: "#15803d",
|
|
76
|
+
warningText: "#b45309",
|
|
77
|
+
selectedRowBackground: "#e5e7eb",
|
|
78
|
+
selectText: "#334155",
|
|
79
|
+
selectSelectedBackground: "#dbeafe",
|
|
80
|
+
selectSelectedText: "#0f172a",
|
|
81
|
+
selectFocusedText: "#111827",
|
|
82
|
+
inputText: "#111827",
|
|
83
|
+
inputFocusedText: "#020617",
|
|
84
|
+
secondaryInputText: "#334155",
|
|
85
|
+
footerReady: "#1d4ed8",
|
|
86
|
+
footerBusy: "#b45309",
|
|
87
|
+
footerError: "#b91c1c",
|
|
88
|
+
diffForeground: "#111827",
|
|
89
|
+
diffLineNumber: "#6b7280",
|
|
90
|
+
diffAddedBackground: "#dff3e4",
|
|
91
|
+
diffRemovedBackground: "#fde2e2",
|
|
92
|
+
diffAddedLineNumberBackground: "#c7e9d0",
|
|
93
|
+
diffRemovedLineNumberBackground: "#f7c6c6",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const DARK_THEME: UiTheme = {
|
|
97
|
+
mode: "dark",
|
|
98
|
+
colors: DARK_COLORS,
|
|
99
|
+
diffSyntaxStyle: createDiffSyntaxStyle("dark"),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const LIGHT_THEME: UiTheme = {
|
|
103
|
+
mode: "light",
|
|
104
|
+
colors: LIGHT_COLORS,
|
|
105
|
+
diffSyntaxStyle: createDiffSyntaxStyle("light"),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const THEME_CACHE_TTL_MS = 1000
|
|
109
|
+
let cachedTheme: UiTheme | null = null
|
|
110
|
+
let cachedThemeAt = 0
|
|
111
|
+
let cachedPreference: UiThemePreference | null = null
|
|
112
|
+
|
|
113
|
+
export function resolveUiTheme(preference: UiThemePreference = "auto"): UiTheme {
|
|
114
|
+
const now = Date.now()
|
|
115
|
+
if (cachedTheme && cachedPreference === preference && now - cachedThemeAt < THEME_CACHE_TTL_MS) {
|
|
116
|
+
return cachedTheme
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const mode = preference === "auto" ? resolveAutoThemeMode() : preference
|
|
120
|
+
cachedTheme = mode === "light" ? LIGHT_THEME : DARK_THEME
|
|
121
|
+
cachedThemeAt = now
|
|
122
|
+
cachedPreference = preference
|
|
123
|
+
return cachedTheme
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resolveAutoThemeMode(): UiThemeMode {
|
|
127
|
+
const explicitBackground = process.env.TERM_BACKGROUND?.trim().toLowerCase()
|
|
128
|
+
if (explicitBackground === "light") return "light"
|
|
129
|
+
if (explicitBackground === "dark") return "dark"
|
|
130
|
+
|
|
131
|
+
const osMode = resolveThemeModeFromMacOs()
|
|
132
|
+
if (osMode) return osMode
|
|
133
|
+
|
|
134
|
+
return "dark"
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function resolveThemeModeFromMacOs(): UiThemeMode | null {
|
|
138
|
+
if (process.platform !== "darwin") return null
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const output = execFileSync("defaults", ["read", "-g", "AppleInterfaceStyle"], {
|
|
142
|
+
encoding: "utf8",
|
|
143
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
144
|
+
timeout: 200,
|
|
145
|
+
maxBuffer: 16 * 1024,
|
|
146
|
+
})
|
|
147
|
+
return output.trim().toLowerCase() === "dark" ? "dark" : "light"
|
|
148
|
+
} catch {
|
|
149
|
+
return "light"
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/ui/types.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type FocusTarget =
|
|
2
|
+
| "files"
|
|
3
|
+
| "branch-dialog-list"
|
|
4
|
+
| "branch-create"
|
|
5
|
+
| "history-commits"
|
|
6
|
+
| "history-actions"
|
|
7
|
+
| "commit-summary"
|
|
8
|
+
| "commit-description"
|
|
9
|
+
export type TopAction = "refresh" | "fetch" | "pull" | "push" | "commit"
|
|
10
|
+
|
|
11
|
+
export const MAIN_FOCUS_ORDER: FocusTarget[] = ["files"]
|
|
12
|
+
export const COMMIT_FOCUS_ORDER: FocusTarget[] = ["commit-summary", "commit-description"]
|
|
13
|
+
|
|
14
|
+
export type FileRow = {
|
|
15
|
+
path: string
|
|
16
|
+
included: boolean
|
|
17
|
+
statusSymbol: string
|
|
18
|
+
statusColor: string
|
|
19
|
+
directory: string
|
|
20
|
+
filename: string
|
|
21
|
+
}
|
package/src/ui/utils.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { ChangedFile } from "../git"
|
|
2
|
+
import type { FileRow } from "./types"
|
|
3
|
+
|
|
4
|
+
export function inferFiletype(path: string | undefined): string | undefined {
|
|
5
|
+
if (!path) return undefined
|
|
6
|
+
const extension = path.includes(".") ? path.split(".").pop() : undefined
|
|
7
|
+
if (!extension) return undefined
|
|
8
|
+
const normalized = extension.toLowerCase()
|
|
9
|
+
|
|
10
|
+
if (normalized === "ts" || normalized === "tsx") return "typescript"
|
|
11
|
+
if (normalized === "js" || normalized === "jsx" || normalized === "mjs" || normalized === "cjs") return "javascript"
|
|
12
|
+
if (normalized === "md" || normalized === "mdx") return "markdown"
|
|
13
|
+
if (normalized === "yml") return "yaml"
|
|
14
|
+
if (normalized === "sh" || normalized === "zsh") return "bash"
|
|
15
|
+
|
|
16
|
+
return normalized
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function fitFooterLine(text: string, width: number): string {
|
|
20
|
+
if (width <= 0) return text
|
|
21
|
+
if (text.length > width) return text.slice(0, width)
|
|
22
|
+
return text.padEnd(width, " ")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function fitFooterStatusLine(left: string, right: string, width: number): string {
|
|
26
|
+
if (width <= 0) return ""
|
|
27
|
+
|
|
28
|
+
const rightText = right.trim()
|
|
29
|
+
if (!rightText) return fitFooterLine(left, width)
|
|
30
|
+
if (rightText.length >= width) return rightText.slice(0, width)
|
|
31
|
+
|
|
32
|
+
const leftWidth = Math.max(width - rightText.length - 1, 0)
|
|
33
|
+
const leftText = fitFooterLine(left, leftWidth)
|
|
34
|
+
return `${leftText} ${rightText}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatTrackingSummary(upstream: string | null, ahead: number, behind: number): string {
|
|
38
|
+
if (!upstream) {
|
|
39
|
+
return "◌ unpublished"
|
|
40
|
+
}
|
|
41
|
+
if (ahead === 0 && behind === 0) {
|
|
42
|
+
return "✓ synced"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const parts: string[] = []
|
|
46
|
+
if (ahead > 0) {
|
|
47
|
+
parts.push(`↑${ahead}`)
|
|
48
|
+
}
|
|
49
|
+
if (behind > 0) {
|
|
50
|
+
parts.push(`↓${behind}`)
|
|
51
|
+
}
|
|
52
|
+
return parts.join(" ")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function buildFileRow(file: ChangedFile, excludedPaths: Set<string>): FileRow {
|
|
56
|
+
const statusSymbol = resolveStatusSymbol(file)
|
|
57
|
+
const statusColor = resolveStatusColor(statusSymbol)
|
|
58
|
+
const pathParts = splitPathParts(file.path)
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
path: file.path,
|
|
62
|
+
included: !excludedPaths.has(file.path),
|
|
63
|
+
statusSymbol,
|
|
64
|
+
statusColor,
|
|
65
|
+
directory: pathParts.directory,
|
|
66
|
+
filename: pathParts.filename,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function splitPathParts(path: string): { directory: string; filename: string } {
|
|
71
|
+
const lastSlash = path.lastIndexOf("/")
|
|
72
|
+
if (lastSlash < 0) {
|
|
73
|
+
return { directory: "", filename: path }
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
directory: path.slice(0, lastSlash + 1),
|
|
77
|
+
filename: path.slice(lastSlash + 1),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveStatusSymbol(file: ChangedFile): string {
|
|
82
|
+
if (file.untracked) return "◌"
|
|
83
|
+
|
|
84
|
+
const statuses = [file.indexStatus, file.worktreeStatus]
|
|
85
|
+
if (statuses.includes("D")) return "−"
|
|
86
|
+
if (statuses.includes("A")) return "+"
|
|
87
|
+
if (statuses.includes("R")) return "→"
|
|
88
|
+
if (statuses.includes("M")) return "●"
|
|
89
|
+
return "·"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolveStatusColor(statusSymbol: string): string {
|
|
93
|
+
if (statusSymbol === "+") return "#3fb950"
|
|
94
|
+
if (statusSymbol === "●") return "#58a6ff"
|
|
95
|
+
if (statusSymbol === "−") return "#ff7b72"
|
|
96
|
+
if (statusSymbol === "→") return "#d2a8ff"
|
|
97
|
+
if (statusSymbol === "◌") return "#d29922"
|
|
98
|
+
return "#9ca3af"
|
|
99
|
+
}
|