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.
@@ -0,0 +1,299 @@
1
+ import { EventEmitter } from "events"
2
+ import type { ViewType, SessionInfo, ProjectInfo, LogFile } from "../types"
3
+ import type { LoadedData } from "../data/loader"
4
+
5
+ // ============================================================================
6
+ // State Interface
7
+ // ============================================================================
8
+
9
+ export interface AppState {
10
+ view: ViewType
11
+ data: LoadedData
12
+ selectedIndices: Set<number>
13
+ currentIndex: number
14
+ expandedProjects: Set<string>
15
+ showConfirm: boolean
16
+ confirmMessage: string
17
+ confirmAction: (() => Promise<void>) | null
18
+ statusMessage: string
19
+ isLoading: boolean
20
+ }
21
+
22
+ // ============================================================================
23
+ // State Events
24
+ // ============================================================================
25
+
26
+ export enum StateEvent {
27
+ VIEW_CHANGED = "view-changed",
28
+ DATA_LOADED = "data-loaded",
29
+ SELECTION_CHANGED = "selection-changed",
30
+ LOADING_CHANGED = "loading-changed",
31
+ STATUS_CHANGED = "status-changed",
32
+ CONFIRM_CHANGED = "confirm-changed",
33
+ EXPAND_CHANGED = "expand-changed",
34
+ }
35
+
36
+ // ============================================================================
37
+ // View Navigation
38
+ // ============================================================================
39
+
40
+ const VIEW_ORDER: ViewType[] = ["main", "orphans", "logs"]
41
+
42
+ export function getNextView(current: ViewType): ViewType {
43
+ const index = VIEW_ORDER.indexOf(current)
44
+ const nextIndex = (index + 1) % VIEW_ORDER.length
45
+ return VIEW_ORDER[nextIndex]
46
+ }
47
+
48
+ export function getPrevView(current: ViewType): ViewType {
49
+ const index = VIEW_ORDER.indexOf(current)
50
+ const prevIndex = (index - 1 + VIEW_ORDER.length) % VIEW_ORDER.length
51
+ return VIEW_ORDER[prevIndex]
52
+ }
53
+
54
+ // ============================================================================
55
+ // State Manager
56
+ // ============================================================================
57
+
58
+ function createInitialState(): AppState {
59
+ return {
60
+ view: "main",
61
+ data: { sessions: [], projects: [], logs: [] },
62
+ selectedIndices: new Set(),
63
+ currentIndex: 0,
64
+ expandedProjects: new Set(),
65
+ showConfirm: false,
66
+ confirmMessage: "",
67
+ confirmAction: null,
68
+ statusMessage: "",
69
+ isLoading: true,
70
+ }
71
+ }
72
+
73
+ export class StateManager extends EventEmitter {
74
+ private state: AppState
75
+
76
+ constructor() {
77
+ super()
78
+ this.state = createInitialState()
79
+ }
80
+
81
+ // ---- Getters ----
82
+
83
+ get view(): ViewType {
84
+ return this.state.view
85
+ }
86
+
87
+ get data(): LoadedData {
88
+ return this.state.data
89
+ }
90
+
91
+ get selectedIndices(): Set<number> {
92
+ return this.state.selectedIndices
93
+ }
94
+
95
+ get currentIndex(): number {
96
+ return this.state.currentIndex
97
+ }
98
+
99
+ get expandedProjects(): Set<string> {
100
+ return this.state.expandedProjects
101
+ }
102
+
103
+ get showConfirm(): boolean {
104
+ return this.state.showConfirm
105
+ }
106
+
107
+ get confirmMessage(): string {
108
+ return this.state.confirmMessage
109
+ }
110
+
111
+ get confirmAction(): (() => Promise<void>) | null {
112
+ return this.state.confirmAction
113
+ }
114
+
115
+ get statusMessage(): string {
116
+ return this.state.statusMessage
117
+ }
118
+
119
+ get isLoading(): boolean {
120
+ return this.state.isLoading
121
+ }
122
+
123
+ // ---- Derived Getters ----
124
+
125
+ get sessions(): SessionInfo[] {
126
+ return this.state.data.sessions
127
+ }
128
+
129
+ get orphanSessions(): SessionInfo[] {
130
+ return this.state.data.sessions.filter((s) => s.isOrphan)
131
+ }
132
+
133
+ get orphanProjects(): ProjectInfo[] {
134
+ return this.state.data.projects.filter((p) => p.isOrphan)
135
+ }
136
+
137
+ get projects(): ProjectInfo[] {
138
+ return this.state.data.projects
139
+ }
140
+
141
+ get logs(): LogFile[] {
142
+ return this.state.data.logs
143
+ }
144
+
145
+ get selectedCount(): number {
146
+ return this.state.selectedIndices.size
147
+ }
148
+
149
+ // ---- Setters with Events ----
150
+
151
+ setView(view: ViewType): void {
152
+ if (this.state.view !== view) {
153
+ this.state.view = view
154
+ this.state.selectedIndices = new Set()
155
+ this.state.currentIndex = 0
156
+ // Don't clear expanded projects when switching away - preserve state
157
+ this.emit(StateEvent.VIEW_CHANGED, view)
158
+ this.emit(StateEvent.SELECTION_CHANGED)
159
+ }
160
+ }
161
+
162
+ nextView(): void {
163
+ this.setView(getNextView(this.state.view))
164
+ }
165
+
166
+ prevView(): void {
167
+ this.setView(getPrevView(this.state.view))
168
+ }
169
+
170
+ setData(data: LoadedData): void {
171
+ this.state.data = data
172
+ this.state.selectedIndices = new Set()
173
+ this.state.currentIndex = 0
174
+ // Clear expanded projects when data changes (projects may have been deleted)
175
+ this.state.expandedProjects = new Set()
176
+ this.emit(StateEvent.DATA_LOADED, data)
177
+ this.emit(StateEvent.SELECTION_CHANGED)
178
+ }
179
+
180
+ setLoading(isLoading: boolean): void {
181
+ if (this.state.isLoading !== isLoading) {
182
+ this.state.isLoading = isLoading
183
+ this.emit(StateEvent.LOADING_CHANGED, isLoading)
184
+ }
185
+ }
186
+
187
+ setStatus(message: string): void {
188
+ this.state.statusMessage = message
189
+ this.emit(StateEvent.STATUS_CHANGED, message)
190
+ }
191
+
192
+ setCurrentIndex(index: number): void {
193
+ this.state.currentIndex = index
194
+ }
195
+
196
+ // ---- Selection Management ----
197
+
198
+ toggleSelection(index: number): void {
199
+ if (this.state.selectedIndices.has(index)) {
200
+ this.state.selectedIndices.delete(index)
201
+ } else {
202
+ this.state.selectedIndices.add(index)
203
+ }
204
+ this.emit(StateEvent.SELECTION_CHANGED)
205
+ }
206
+
207
+ selectAll(itemCount: number): void {
208
+ if (this.state.selectedIndices.size === itemCount) {
209
+ this.state.selectedIndices = new Set()
210
+ } else {
211
+ this.state.selectedIndices = new Set(
212
+ Array.from({ length: itemCount }, (_, i) => i)
213
+ )
214
+ }
215
+ this.emit(StateEvent.SELECTION_CHANGED)
216
+ }
217
+
218
+ clearSelection(): void {
219
+ this.state.selectedIndices = new Set()
220
+ this.emit(StateEvent.SELECTION_CHANGED)
221
+ }
222
+
223
+ getSelectedOrCurrentIndices(currentIndex: number): number[] {
224
+ if (this.state.selectedIndices.size > 0) {
225
+ return Array.from(this.state.selectedIndices)
226
+ }
227
+ if (currentIndex >= 0) {
228
+ return [currentIndex]
229
+ }
230
+ return []
231
+ }
232
+
233
+ // ---- Project Expansion (for main view) ----
234
+
235
+ isProjectExpanded(projectId: string): boolean {
236
+ return this.state.expandedProjects.has(projectId)
237
+ }
238
+
239
+ expandProject(projectId: string): void {
240
+ if (!this.state.expandedProjects.has(projectId)) {
241
+ this.state.expandedProjects.add(projectId)
242
+ this.emit(StateEvent.EXPAND_CHANGED, projectId)
243
+ }
244
+ }
245
+
246
+ collapseProject(projectId: string): void {
247
+ if (this.state.expandedProjects.has(projectId)) {
248
+ this.state.expandedProjects.delete(projectId)
249
+ this.emit(StateEvent.EXPAND_CHANGED, projectId)
250
+ }
251
+ }
252
+
253
+ toggleProjectExpand(projectId: string): void {
254
+ if (this.isProjectExpanded(projectId)) {
255
+ this.collapseProject(projectId)
256
+ } else {
257
+ this.expandProject(projectId)
258
+ }
259
+ }
260
+
261
+ collapseAllProjects(): void {
262
+ if (this.state.expandedProjects.size > 0) {
263
+ this.state.expandedProjects = new Set()
264
+ this.state.selectedIndices = new Set()
265
+ this.emit(StateEvent.EXPAND_CHANGED, null)
266
+ this.emit(StateEvent.SELECTION_CHANGED)
267
+ }
268
+ }
269
+
270
+ // ---- Confirm Dialog ----
271
+
272
+ showConfirmDialog(message: string, action: () => Promise<void>): void {
273
+ this.state.showConfirm = true
274
+ this.state.confirmMessage = message
275
+ this.state.confirmAction = action
276
+ this.emit(StateEvent.CONFIRM_CHANGED, true)
277
+ }
278
+
279
+ hideConfirmDialog(): void {
280
+ this.state.showConfirm = false
281
+ this.state.confirmMessage = ""
282
+ this.state.confirmAction = null
283
+ this.emit(StateEvent.CONFIRM_CHANGED, false)
284
+ }
285
+
286
+ async executeConfirmAction(): Promise<void> {
287
+ const action = this.state.confirmAction
288
+ this.hideConfirmDialog()
289
+ if (action) {
290
+ await action()
291
+ }
292
+ }
293
+
294
+ // ---- Reset ----
295
+
296
+ reset(): void {
297
+ this.state = createInitialState()
298
+ }
299
+ }
@@ -0,0 +1,92 @@
1
+ import type { SelectOption } from "@opentui/core"
2
+ import type { ViewType } from "../../types"
3
+ import type { StateManager } from "../state"
4
+
5
+ // ============================================================================
6
+ // Delete Result Type
7
+ // ============================================================================
8
+
9
+ export interface DeleteResult {
10
+ deletedCount: number
11
+ freedBytes: number
12
+ errors: string[]
13
+ }
14
+
15
+ // ============================================================================
16
+ // View Configuration
17
+ // ============================================================================
18
+
19
+ export interface ViewConfig {
20
+ id: ViewType
21
+ title: string
22
+ supportsSelectAll: boolean
23
+ supportsDeleteAll: boolean
24
+ }
25
+
26
+ // ============================================================================
27
+ // Base View Abstract Class
28
+ // ============================================================================
29
+
30
+ // ViewItem is a union of all possible item types across views
31
+ // Each view can return any type that can be used in the UI
32
+ export type ViewItem = unknown
33
+
34
+ export abstract class BaseView {
35
+ protected state: StateManager
36
+
37
+ constructor(state: StateManager) {
38
+ this.state = state
39
+ }
40
+
41
+ /**
42
+ * View configuration
43
+ */
44
+ abstract readonly config: ViewConfig
45
+
46
+ /**
47
+ * Get the items for this view
48
+ */
49
+ abstract getItems(): ViewItem[]
50
+
51
+ /**
52
+ * Convert items to SelectOption format for display
53
+ */
54
+ abstract getOptions(selectedIndices: Set<number>): SelectOption[]
55
+
56
+ /**
57
+ * Get the confirmation message for deletion
58
+ */
59
+ abstract getDeleteMessage(count: number, totalSize: number): string
60
+
61
+ /**
62
+ * Execute deletion of items at the given indices
63
+ */
64
+ abstract executeDelete(indices: number[]): Promise<DeleteResult>
65
+
66
+ /**
67
+ * Get the title with counts
68
+ */
69
+ getTitle(selectedCount: number): string {
70
+ const items = this.getItems()
71
+ const { title } = this.config
72
+ let result = `${title} (${items.length} total`
73
+ if (selectedCount > 0) {
74
+ result += `, ${selectedCount} selected`
75
+ }
76
+ result += ")"
77
+ return result
78
+ }
79
+
80
+ /**
81
+ * Calculate total size of selected items
82
+ * Each view should override this with proper type handling
83
+ */
84
+ abstract getTotalSize(indices: number[]): number
85
+
86
+ /**
87
+ * Get the number of items in this view
88
+ */
89
+ getItemCount(): number {
90
+ return this.getItems().length
91
+ }
92
+ }
@@ -0,0 +1,45 @@
1
+ import type { ViewType } from "../../types"
2
+ import type { StateManager } from "../state"
3
+ import { BaseView } from "./base-view"
4
+ import { MainView } from "./main-view"
5
+ import { OrphanListView } from "./orphan-list"
6
+ import { LogListView } from "./log-list"
7
+
8
+ // ============================================================================
9
+ // Exports
10
+ // ============================================================================
11
+
12
+ export { BaseView, type ViewConfig, type DeleteResult, type ViewItem } from "./base-view"
13
+ export { MainView, type ListItem, type ProjectItem, type SessionItem } from "./main-view"
14
+ export { OrphanListView } from "./orphan-list"
15
+ export { LogListView } from "./log-list"
16
+
17
+ // ============================================================================
18
+ // View Factory
19
+ // ============================================================================
20
+
21
+ export type ViewMap = {
22
+ main: MainView
23
+ orphans: OrphanListView
24
+ logs: LogListView
25
+ }
26
+
27
+ export function createViews(state: StateManager): ViewMap {
28
+ return {
29
+ main: new MainView(state),
30
+ orphans: new OrphanListView(state),
31
+ logs: new LogListView(state),
32
+ }
33
+ }
34
+
35
+ export function getView(views: ViewMap, viewType: ViewType): BaseView {
36
+ return views[viewType]
37
+ }
38
+
39
+ export function getMainView(views: ViewMap): MainView {
40
+ return views.main
41
+ }
42
+
43
+ export function getOrphanView(views: ViewMap): OrphanListView {
44
+ return views.orphans
45
+ }
@@ -0,0 +1,81 @@
1
+ import type { SelectOption } from "@opentui/core"
2
+ import type { LogFile } from "../../types"
3
+ import type { StateManager } from "../state"
4
+ import { formatBytes, formatDate } from "../../utils"
5
+ import { deleteLogs } from "../../data/manager"
6
+ import { BaseView, type ViewConfig, type DeleteResult } from "./base-view"
7
+
8
+ // ============================================================================
9
+ // Log Files List View
10
+ // ============================================================================
11
+
12
+ export class LogListView extends BaseView {
13
+ readonly config: ViewConfig = {
14
+ id: "logs",
15
+ title: "Log Files",
16
+ supportsSelectAll: true,
17
+ supportsDeleteAll: true,
18
+ }
19
+
20
+ constructor(state: StateManager) {
21
+ super(state)
22
+ }
23
+
24
+ getItems(): LogFile[] {
25
+ return this.state.logs
26
+ }
27
+
28
+ getOptions(selectedIndices: Set<number>): SelectOption[] {
29
+ const logs = this.getItems()
30
+ return logs.map((log, index) => {
31
+ const selected = selectedIndices.has(index) ? "[x]" : "[ ]"
32
+ const date = formatDate(log.date)
33
+ const size = formatBytes(log.sizeBytes)
34
+
35
+ return {
36
+ name: `${selected} ${log.filename}`,
37
+ description: `${date} | ${size}`,
38
+ value: log.path,
39
+ }
40
+ })
41
+ }
42
+
43
+ getDeleteMessage(count: number, totalSize: number): string {
44
+ return `Delete ${count} log file(s)? (${formatBytes(totalSize)}) [y/N]`
45
+ }
46
+
47
+ getDeleteAllMessage(totalSize: number): string {
48
+ const count = this.getItemCount()
49
+ return `Delete ALL ${count} log file(s)? (${formatBytes(totalSize)}) [y/N]`
50
+ }
51
+
52
+ getTotalSize(indices: number[]): number {
53
+ const logs = this.getItems()
54
+ return indices.reduce((sum, index) => {
55
+ const log = logs[index]
56
+ return log ? sum + log.sizeBytes : sum
57
+ }, 0)
58
+ }
59
+
60
+ async executeDelete(indices: number[]): Promise<DeleteResult> {
61
+ const logs = this.getItems()
62
+ const toDelete = indices.map((i) => logs[i]).filter(Boolean)
63
+
64
+ let deletedCount = 0
65
+ let freedBytes = 0
66
+ const errors: string[] = []
67
+
68
+ const results = await deleteLogs(toDelete)
69
+
70
+ for (const result of results) {
71
+ if (result.success) {
72
+ deletedCount++
73
+ freedBytes += result.bytesFreed
74
+ } else if (result.error) {
75
+ errors.push(`${result.filename}: ${result.error}`)
76
+ }
77
+ }
78
+
79
+ return { deletedCount, freedBytes, errors }
80
+ }
81
+ }