opencode-session 0.1.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.
- package/LICENSE +21 -0
- package/package.json +46 -0
- package/src/config.ts +29 -0
- package/src/data/loader.ts +495 -0
- package/src/data/manager.ts +312 -0
- package/src/index.ts +56 -0
- package/src/types.ts +173 -0
- package/src/ui/app.ts +245 -0
- package/src/ui/components/confirm-dialog.ts +117 -0
- package/src/ui/components/detail-viewer.ts +307 -0
- package/src/ui/components/header.ts +62 -0
- package/src/ui/components/index.ts +8 -0
- package/src/ui/components/list-container.ts +97 -0
- package/src/ui/components/log-viewer.ts +217 -0
- package/src/ui/components/status-bar.ts +99 -0
- package/src/ui/components/tabbar.ts +79 -0
- package/src/ui/controllers/confirm-controller.ts +57 -0
- package/src/ui/controllers/index.ts +92 -0
- package/src/ui/controllers/log-controller.ts +173 -0
- package/src/ui/controllers/log-viewer-controller.ts +52 -0
- package/src/ui/controllers/main-controller.ts +142 -0
- package/src/ui/controllers/message-viewer-controller.ts +176 -0
- package/src/ui/controllers/orphan-controller.ts +125 -0
- package/src/ui/controllers/project-viewer-controller.ts +113 -0
- package/src/ui/controllers/session-viewer-controller.ts +181 -0
- package/src/ui/keybindings.ts +158 -0
- package/src/ui/state.ts +299 -0
- package/src/ui/views/base-view.ts +92 -0
- package/src/ui/views/index.ts +45 -0
- package/src/ui/views/log-list.ts +81 -0
- package/src/ui/views/main-view.ts +242 -0
- package/src/ui/views/orphan-list.ts +241 -0
- package/src/utils.ts +118 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextRenderable,
|
|
4
|
+
type CliRenderer,
|
|
5
|
+
} from "@opentui/core"
|
|
6
|
+
import { APP_NAME, APP_VERSION } from "../../config"
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Header Component
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
export class Header {
|
|
13
|
+
private box: BoxRenderable
|
|
14
|
+
private text: TextRenderable
|
|
15
|
+
|
|
16
|
+
constructor(renderer: CliRenderer) {
|
|
17
|
+
this.box = new BoxRenderable(renderer, {
|
|
18
|
+
id: "header-box",
|
|
19
|
+
width: "100%",
|
|
20
|
+
height: 3,
|
|
21
|
+
borderStyle: "single",
|
|
22
|
+
borderColor: "#666666",
|
|
23
|
+
padding: 1,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
this.text = new TextRenderable(renderer, {
|
|
27
|
+
id: "header-text",
|
|
28
|
+
content: `${APP_NAME} v${APP_VERSION}`,
|
|
29
|
+
fg: "#FFFFFF",
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
this.box.add(this.text)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the root renderable for this component
|
|
37
|
+
*/
|
|
38
|
+
getRenderable(): BoxRenderable {
|
|
39
|
+
return this.box
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Set counts (total items and selected)
|
|
44
|
+
*/
|
|
45
|
+
setCounts(totalCount: number, selectedCount: number): void {
|
|
46
|
+
let text = `${APP_NAME} v${APP_VERSION}`
|
|
47
|
+
if (totalCount > 0) {
|
|
48
|
+
text += ` | ${totalCount} items`
|
|
49
|
+
if (selectedCount > 0) {
|
|
50
|
+
text += `, ${selectedCount} selected`
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
this.text.content = text
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Set loading state with message
|
|
58
|
+
*/
|
|
59
|
+
setLoading(message: string = "Loading..."): void {
|
|
60
|
+
this.text.content = `${APP_NAME} v${APP_VERSION} - ${message}`
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { Header } from "./header"
|
|
2
|
+
export { TabBar } from "./tabbar"
|
|
3
|
+
export { StatusBar } from "./status-bar"
|
|
4
|
+
export { ConfirmDialog } from "./confirm-dialog"
|
|
5
|
+
export { ListContainer } from "./list-container"
|
|
6
|
+
export { LogViewer } from "./log-viewer"
|
|
7
|
+
export { DetailViewer } from "./detail-viewer"
|
|
8
|
+
export type { DetailViewerItem, DetailViewerData } from "./detail-viewer"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
SelectRenderable,
|
|
4
|
+
TextRenderable,
|
|
5
|
+
type CliRenderer,
|
|
6
|
+
type SelectOption,
|
|
7
|
+
} from "@opentui/core"
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// List Container Component
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
export class ListContainer {
|
|
14
|
+
private box: BoxRenderable
|
|
15
|
+
private select: SelectRenderable
|
|
16
|
+
private emptyText: TextRenderable
|
|
17
|
+
|
|
18
|
+
constructor(renderer: CliRenderer) {
|
|
19
|
+
this.box = new BoxRenderable(renderer, {
|
|
20
|
+
id: "list-box",
|
|
21
|
+
width: "100%",
|
|
22
|
+
flexGrow: 1,
|
|
23
|
+
borderStyle: "single",
|
|
24
|
+
borderColor: "#444444",
|
|
25
|
+
overflow: "hidden",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
this.select = new SelectRenderable(renderer, {
|
|
29
|
+
id: "select-list",
|
|
30
|
+
width: "100%",
|
|
31
|
+
height: "100%",
|
|
32
|
+
options: [],
|
|
33
|
+
showDescription: true,
|
|
34
|
+
wrapSelection: true,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
this.emptyText = new TextRenderable(renderer, {
|
|
38
|
+
id: "empty-text",
|
|
39
|
+
content: " No items",
|
|
40
|
+
fg: "#666666",
|
|
41
|
+
})
|
|
42
|
+
this.emptyText.visible = false
|
|
43
|
+
|
|
44
|
+
this.box.add(this.select)
|
|
45
|
+
this.box.add(this.emptyText)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get the root renderable for this component
|
|
50
|
+
*/
|
|
51
|
+
getRenderable(): BoxRenderable {
|
|
52
|
+
return this.box
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Set the options to display in the list
|
|
57
|
+
*/
|
|
58
|
+
setOptions(options: SelectOption[]): void {
|
|
59
|
+
if (options.length === 0) {
|
|
60
|
+
this.select.visible = false
|
|
61
|
+
this.emptyText.visible = true
|
|
62
|
+
} else {
|
|
63
|
+
this.select.visible = true
|
|
64
|
+
this.emptyText.visible = false
|
|
65
|
+
this.select.options = options
|
|
66
|
+
this.select.focus() // Re-focus after making visible
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get the currently selected index
|
|
72
|
+
*/
|
|
73
|
+
getSelectedIndex(): number {
|
|
74
|
+
return this.select.getSelectedIndex()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Set the selected index
|
|
79
|
+
*/
|
|
80
|
+
setSelectedIndex(index: number): void {
|
|
81
|
+
this.select.selectedIndex = index
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Focus the list for keyboard input
|
|
86
|
+
*/
|
|
87
|
+
focus(): void {
|
|
88
|
+
this.select.focus()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get the number of options
|
|
93
|
+
*/
|
|
94
|
+
getOptionCount(): number {
|
|
95
|
+
return this.select.options.length
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextRenderable,
|
|
4
|
+
type CliRenderer,
|
|
5
|
+
} from "@opentui/core"
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Log Viewer Component
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
export class LogViewer {
|
|
12
|
+
private box: BoxRenderable
|
|
13
|
+
private titleText: TextRenderable
|
|
14
|
+
private contentText: TextRenderable
|
|
15
|
+
private hintsText: TextRenderable
|
|
16
|
+
|
|
17
|
+
private lines: string[] = []
|
|
18
|
+
private scrollOffset = 0
|
|
19
|
+
private viewportHeight = 0
|
|
20
|
+
private contentWidth = 0
|
|
21
|
+
|
|
22
|
+
constructor(private renderer: CliRenderer) {
|
|
23
|
+
// Full-screen overlay box
|
|
24
|
+
this.box = new BoxRenderable(renderer, {
|
|
25
|
+
id: "log-viewer-box",
|
|
26
|
+
width: "100%",
|
|
27
|
+
height: "100%",
|
|
28
|
+
position: "absolute",
|
|
29
|
+
left: 0,
|
|
30
|
+
top: 0,
|
|
31
|
+
borderStyle: "double",
|
|
32
|
+
borderColor: "#FF6600",
|
|
33
|
+
backgroundColor: "#1a1a1a",
|
|
34
|
+
visible: false,
|
|
35
|
+
padding: 1,
|
|
36
|
+
zIndex: 300,
|
|
37
|
+
flexDirection: "column",
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// Title bar
|
|
41
|
+
this.titleText = new TextRenderable(renderer, {
|
|
42
|
+
id: "log-viewer-title",
|
|
43
|
+
content: "",
|
|
44
|
+
fg: "#FF6600",
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// Scrollable content area
|
|
48
|
+
this.contentText = new TextRenderable(renderer, {
|
|
49
|
+
id: "log-viewer-content",
|
|
50
|
+
content: "",
|
|
51
|
+
fg: "#FFFFFF",
|
|
52
|
+
flexGrow: 1,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Hints at the bottom
|
|
56
|
+
this.hintsText = new TextRenderable(renderer, {
|
|
57
|
+
id: "log-viewer-hints",
|
|
58
|
+
content: "j/k:scroll g/G:top/bottom Esc/q:back",
|
|
59
|
+
fg: "#888888",
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
this.box.add(this.titleText)
|
|
63
|
+
this.box.add(this.contentText)
|
|
64
|
+
this.box.add(this.hintsText)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get the root renderable for this component
|
|
69
|
+
*/
|
|
70
|
+
getRenderable(): BoxRenderable {
|
|
71
|
+
return this.box
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Show the log viewer with the given content
|
|
76
|
+
*/
|
|
77
|
+
show(filename: string, content: string): void {
|
|
78
|
+
this.titleText.content = `Log: ${filename}`
|
|
79
|
+
|
|
80
|
+
// Calculate viewport dimensions (accounting for border, padding, title, hints)
|
|
81
|
+
// Box has padding=1 (top+bottom), border=1 (top+bottom), title=1, hints=1
|
|
82
|
+
const terminalHeight = this.renderer.terminalHeight
|
|
83
|
+
const terminalWidth = this.renderer.terminalWidth
|
|
84
|
+
this.viewportHeight = terminalHeight - 8 // border(2) + padding(2) + title(1) + hints(1) + some margin
|
|
85
|
+
this.contentWidth = terminalWidth - 6 // border(2) + padding(2) + line number gutter(~6)
|
|
86
|
+
|
|
87
|
+
// Split and wrap lines
|
|
88
|
+
this.lines = this.wrapContent(content)
|
|
89
|
+
this.scrollOffset = 0
|
|
90
|
+
|
|
91
|
+
this.updateContent()
|
|
92
|
+
this.box.visible = true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Hide the log viewer
|
|
97
|
+
*/
|
|
98
|
+
hide(): void {
|
|
99
|
+
this.box.visible = false
|
|
100
|
+
this.lines = []
|
|
101
|
+
this.scrollOffset = 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check if the viewer is currently visible
|
|
106
|
+
*/
|
|
107
|
+
isVisible(): boolean {
|
|
108
|
+
return this.box.visible
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Scroll up by one line
|
|
113
|
+
*/
|
|
114
|
+
scrollUp(): void {
|
|
115
|
+
if (this.scrollOffset > 0) {
|
|
116
|
+
this.scrollOffset--
|
|
117
|
+
this.updateContent()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Scroll down by one line
|
|
123
|
+
*/
|
|
124
|
+
scrollDown(): void {
|
|
125
|
+
const maxOffset = Math.max(0, this.lines.length - this.viewportHeight)
|
|
126
|
+
if (this.scrollOffset < maxOffset) {
|
|
127
|
+
this.scrollOffset++
|
|
128
|
+
this.updateContent()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Scroll to the top
|
|
134
|
+
*/
|
|
135
|
+
scrollToTop(): void {
|
|
136
|
+
this.scrollOffset = 0
|
|
137
|
+
this.updateContent()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Scroll to the bottom
|
|
142
|
+
*/
|
|
143
|
+
scrollToBottom(): void {
|
|
144
|
+
this.scrollOffset = Math.max(0, this.lines.length - this.viewportHeight)
|
|
145
|
+
this.updateContent()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --------------------------------------------------------------------------
|
|
149
|
+
// Private Methods
|
|
150
|
+
// --------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Wrap long lines and add line numbers
|
|
154
|
+
*/
|
|
155
|
+
private wrapContent(content: string): string[] {
|
|
156
|
+
const rawLines = content.split("\n")
|
|
157
|
+
const wrappedLines: string[] = []
|
|
158
|
+
|
|
159
|
+
// Calculate the gutter width based on total line count
|
|
160
|
+
const gutterWidth = Math.max(4, String(rawLines.length).length + 1)
|
|
161
|
+
const maxLineWidth = Math.max(20, this.contentWidth - gutterWidth - 2) // -2 for " | " separator
|
|
162
|
+
|
|
163
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
164
|
+
const lineNum = i + 1
|
|
165
|
+
const rawLine = rawLines[i]
|
|
166
|
+
|
|
167
|
+
if (rawLine.length <= maxLineWidth) {
|
|
168
|
+
// Line fits, add with line number
|
|
169
|
+
const lineNumStr = String(lineNum).padStart(gutterWidth)
|
|
170
|
+
wrappedLines.push(`${lineNumStr} | ${rawLine}`)
|
|
171
|
+
} else {
|
|
172
|
+
// Line needs wrapping
|
|
173
|
+
let remaining = rawLine
|
|
174
|
+
let isFirstSegment = true
|
|
175
|
+
|
|
176
|
+
while (remaining.length > 0) {
|
|
177
|
+
const segment = remaining.slice(0, maxLineWidth)
|
|
178
|
+
remaining = remaining.slice(maxLineWidth)
|
|
179
|
+
|
|
180
|
+
if (isFirstSegment) {
|
|
181
|
+
const lineNumStr = String(lineNum).padStart(gutterWidth)
|
|
182
|
+
wrappedLines.push(`${lineNumStr} | ${segment}`)
|
|
183
|
+
isFirstSegment = false
|
|
184
|
+
} else {
|
|
185
|
+
// Continuation lines show empty gutter
|
|
186
|
+
const emptyGutter = " ".repeat(gutterWidth)
|
|
187
|
+
wrappedLines.push(`${emptyGutter} | ${segment}`)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return wrappedLines
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Update the visible content based on scroll offset
|
|
198
|
+
*/
|
|
199
|
+
private updateContent(): void {
|
|
200
|
+
const visibleLines = this.lines.slice(
|
|
201
|
+
this.scrollOffset,
|
|
202
|
+
this.scrollOffset + this.viewportHeight
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
this.contentText.content = visibleLines.join("\n")
|
|
206
|
+
|
|
207
|
+
// Update hints with scroll position
|
|
208
|
+
const totalLines = this.lines.length
|
|
209
|
+
const currentLine = this.scrollOffset + 1
|
|
210
|
+
const endLine = Math.min(this.scrollOffset + this.viewportHeight, totalLines)
|
|
211
|
+
const scrollInfo = totalLines > this.viewportHeight
|
|
212
|
+
? ` [${currentLine}-${endLine}/${totalLines}]`
|
|
213
|
+
: ""
|
|
214
|
+
|
|
215
|
+
this.hintsText.content = `j/k:scroll g/G:top/bottom Esc/q:back${scrollInfo}`
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextRenderable,
|
|
4
|
+
type CliRenderer,
|
|
5
|
+
} from "@opentui/core"
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Status Bar Component
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
export class StatusBar {
|
|
12
|
+
private box: BoxRenderable
|
|
13
|
+
private text: TextRenderable
|
|
14
|
+
private statusTimeout: ReturnType<typeof setTimeout> | null = null
|
|
15
|
+
private currentHints: string = ""
|
|
16
|
+
private currentMessage: string = ""
|
|
17
|
+
|
|
18
|
+
constructor(renderer: CliRenderer) {
|
|
19
|
+
this.box = new BoxRenderable(renderer, {
|
|
20
|
+
id: "status-box",
|
|
21
|
+
width: "100%",
|
|
22
|
+
height: 3,
|
|
23
|
+
borderStyle: "single",
|
|
24
|
+
borderColor: "#666666",
|
|
25
|
+
padding: 1,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
this.text = new TextRenderable(renderer, {
|
|
29
|
+
id: "status-text",
|
|
30
|
+
content: "",
|
|
31
|
+
fg: "#888888",
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
this.box.add(this.text)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get the root renderable for this component
|
|
39
|
+
*/
|
|
40
|
+
getRenderable(): BoxRenderable {
|
|
41
|
+
return this.box
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Set the keybinding hints for the current view
|
|
46
|
+
*/
|
|
47
|
+
setHints(hints: string): void {
|
|
48
|
+
this.currentHints = hints
|
|
49
|
+
this.updateDisplay()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Set a temporary status message
|
|
54
|
+
* @param message The message to display
|
|
55
|
+
* @param duration Duration in ms before clearing (default: 3000)
|
|
56
|
+
*/
|
|
57
|
+
setMessage(message: string, duration: number = 3000): void {
|
|
58
|
+
// Clear any existing timeout
|
|
59
|
+
if (this.statusTimeout) {
|
|
60
|
+
clearTimeout(this.statusTimeout)
|
|
61
|
+
this.statusTimeout = null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.currentMessage = message
|
|
65
|
+
this.updateDisplay()
|
|
66
|
+
|
|
67
|
+
// Auto-clear message after duration
|
|
68
|
+
if (duration > 0) {
|
|
69
|
+
this.statusTimeout = setTimeout(() => {
|
|
70
|
+
this.currentMessage = ""
|
|
71
|
+
this.updateDisplay()
|
|
72
|
+
this.statusTimeout = null
|
|
73
|
+
}, duration)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Clear the status message
|
|
79
|
+
*/
|
|
80
|
+
clearMessage(): void {
|
|
81
|
+
if (this.statusTimeout) {
|
|
82
|
+
clearTimeout(this.statusTimeout)
|
|
83
|
+
this.statusTimeout = null
|
|
84
|
+
}
|
|
85
|
+
this.currentMessage = ""
|
|
86
|
+
this.updateDisplay()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Update the display text
|
|
91
|
+
*/
|
|
92
|
+
private updateDisplay(): void {
|
|
93
|
+
if (this.currentMessage) {
|
|
94
|
+
this.text.content = `${this.currentMessage} | ${this.currentHints}`
|
|
95
|
+
} else {
|
|
96
|
+
this.text.content = this.currentHints
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextRenderable,
|
|
4
|
+
type CliRenderer,
|
|
5
|
+
} from "@opentui/core"
|
|
6
|
+
import type { ViewType } from "../../types"
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Tab Configuration
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
interface TabConfig {
|
|
13
|
+
id: ViewType
|
|
14
|
+
label: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const TABS: TabConfig[] = [
|
|
18
|
+
{ id: "main", label: "Projects" },
|
|
19
|
+
{ id: "orphans", label: "Orphans" },
|
|
20
|
+
{ id: "logs", label: "Logs" },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// TabBar Component
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
export class TabBar {
|
|
28
|
+
private box: BoxRenderable
|
|
29
|
+
private text: TextRenderable
|
|
30
|
+
private activeView: ViewType = "main"
|
|
31
|
+
|
|
32
|
+
constructor(renderer: CliRenderer) {
|
|
33
|
+
this.box = new BoxRenderable(renderer, {
|
|
34
|
+
id: "tabbar-box",
|
|
35
|
+
width: "100%",
|
|
36
|
+
height: 3,
|
|
37
|
+
borderStyle: "single",
|
|
38
|
+
borderColor: "#444444",
|
|
39
|
+
padding: 1,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
this.text = new TextRenderable(renderer, {
|
|
43
|
+
id: "tabbar-text",
|
|
44
|
+
content: this.buildTabText("main"),
|
|
45
|
+
fg: "#FFFFFF",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
this.box.add(this.text)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get the root renderable for this component
|
|
53
|
+
*/
|
|
54
|
+
getRenderable(): BoxRenderable {
|
|
55
|
+
return this.box
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Set the active tab and update display
|
|
60
|
+
*/
|
|
61
|
+
setActiveTab(view: ViewType): void {
|
|
62
|
+
if (this.activeView !== view) {
|
|
63
|
+
this.activeView = view
|
|
64
|
+
this.text.content = this.buildTabText(view)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build the tab text with the active tab bracketed
|
|
70
|
+
*/
|
|
71
|
+
private buildTabText(activeView: ViewType): string {
|
|
72
|
+
return TABS.map((tab) => {
|
|
73
|
+
if (tab.id === activeView) {
|
|
74
|
+
return `[${tab.label}]`
|
|
75
|
+
}
|
|
76
|
+
return ` ${tab.label} `
|
|
77
|
+
}).join(" ")
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ViewController, ControllerContext } from "./index"
|
|
2
|
+
import { Action, CONFIRM_KEYBINDINGS, type KeyBinding } from "../keybindings"
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// Confirm Dialog Types
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
export interface ConfirmField {
|
|
9
|
+
label: string
|
|
10
|
+
value: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ConfirmDetails {
|
|
14
|
+
title: string // e.g., "Delete Project", "Delete Session"
|
|
15
|
+
fields: ConfirmField[] // e.g., [{ label: "Path:", value: "~/foo" }]
|
|
16
|
+
onConfirm: () => Promise<void>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Confirm Dialog Controller
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
export class ConfirmDialogController implements ViewController {
|
|
24
|
+
constructor(private details: ConfirmDetails) {}
|
|
25
|
+
|
|
26
|
+
onEnter(ctx: ControllerContext): void {
|
|
27
|
+
ctx.confirmDialog.showDetails(this.details.title, this.details.fields)
|
|
28
|
+
ctx.statusBar.setHints("y:confirm n/Esc:cancel")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
onExit(ctx: ControllerContext): void {
|
|
32
|
+
ctx.confirmDialog.hide()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
handleAction(action: Action, ctx: ControllerContext): boolean {
|
|
36
|
+
switch (action) {
|
|
37
|
+
case Action.CONFIRM_YES:
|
|
38
|
+
ctx.popController()
|
|
39
|
+
this.details.onConfirm()
|
|
40
|
+
return true
|
|
41
|
+
case Action.CONFIRM_NO:
|
|
42
|
+
case Action.BACK:
|
|
43
|
+
ctx.popController()
|
|
44
|
+
return true
|
|
45
|
+
default:
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getHelpText(): string {
|
|
51
|
+
return "y:confirm n/Esc:cancel"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getKeybindings(): KeyBinding[] {
|
|
55
|
+
return CONFIRM_KEYBINDINGS
|
|
56
|
+
}
|
|
57
|
+
}
|