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,242 @@
|
|
|
1
|
+
import type { SelectOption } from "@opentui/core"
|
|
2
|
+
import type { ProjectInfo, SessionInfo } from "../../types"
|
|
3
|
+
import type { StateManager } from "../state"
|
|
4
|
+
import { formatBytes, formatRelativeTime, truncatePath } from "../../utils"
|
|
5
|
+
import { deleteSessions, deleteProject, cleanupEmptyProject } from "../../data/manager"
|
|
6
|
+
import { BaseView, type ViewConfig, type DeleteResult } from "./base-view"
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// List Item Types
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
export interface ProjectItem {
|
|
13
|
+
type: "project"
|
|
14
|
+
project: ProjectInfo
|
|
15
|
+
projectIndex: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SessionItem {
|
|
19
|
+
type: "session"
|
|
20
|
+
session: SessionInfo
|
|
21
|
+
project: ProjectInfo
|
|
22
|
+
projectIndex: number
|
|
23
|
+
sessionIndex: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ListItem = ProjectItem | SessionItem
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Main View - Unified Projects & Sessions
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
export class MainView extends BaseView {
|
|
33
|
+
readonly config: ViewConfig = {
|
|
34
|
+
id: "main",
|
|
35
|
+
title: "Projects & Sessions",
|
|
36
|
+
supportsSelectAll: false,
|
|
37
|
+
supportsDeleteAll: false,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
constructor(state: StateManager) {
|
|
41
|
+
super(state)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build flat list of items including projects and their expanded sessions
|
|
46
|
+
*/
|
|
47
|
+
private buildItemList(): ListItem[] {
|
|
48
|
+
const items: ListItem[] = []
|
|
49
|
+
const projects = this.state.projects
|
|
50
|
+
|
|
51
|
+
for (let projectIndex = 0; projectIndex < projects.length; projectIndex++) {
|
|
52
|
+
const project = projects[projectIndex]
|
|
53
|
+
|
|
54
|
+
// Add project item
|
|
55
|
+
items.push({
|
|
56
|
+
type: "project",
|
|
57
|
+
project,
|
|
58
|
+
projectIndex,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// If expanded, add session items
|
|
62
|
+
if (this.state.isProjectExpanded(project.id)) {
|
|
63
|
+
for (let sessionIndex = 0; sessionIndex < project.sessions.length; sessionIndex++) {
|
|
64
|
+
const session = project.sessions[sessionIndex]
|
|
65
|
+
items.push({
|
|
66
|
+
type: "session",
|
|
67
|
+
session,
|
|
68
|
+
project,
|
|
69
|
+
projectIndex,
|
|
70
|
+
sessionIndex,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return items
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getItems(): ListItem[] {
|
|
80
|
+
return this.buildItemList()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getOptions(_selectedIndices: Set<number>): SelectOption[] {
|
|
84
|
+
const items = this.getItems()
|
|
85
|
+
|
|
86
|
+
return items.map((item) => {
|
|
87
|
+
if (item.type === "project") {
|
|
88
|
+
const { project } = item
|
|
89
|
+
const expanded = this.state.isProjectExpanded(project.id)
|
|
90
|
+
const expandIcon = expanded ? "v" : ">"
|
|
91
|
+
const orphan = project.isOrphan ? " [ORPHAN]" : ""
|
|
92
|
+
const dir = truncatePath(project.worktree, 40)
|
|
93
|
+
const sessions = `${project.sessionCount} sessions`
|
|
94
|
+
const size = formatBytes(project.totalSizeBytes)
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
name: `${expandIcon} ${dir}${orphan}`,
|
|
98
|
+
description: `${sessions} | ${size}`,
|
|
99
|
+
value: `project:${project.id}`,
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
// Session item - indented
|
|
103
|
+
const { session } = item
|
|
104
|
+
const title = session.title || session.slug || session.id.slice(0, 12)
|
|
105
|
+
const updated = formatRelativeTime(session.time.updated)
|
|
106
|
+
const size = formatBytes(session.sizeBytes)
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
name: ` ${title}`,
|
|
110
|
+
description: ` ${updated} | ${size}`,
|
|
111
|
+
value: `session:${session.id}`,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
getDeleteMessage(count: number, totalSize: number): string {
|
|
118
|
+
return `Delete ${count} item(s)? (${formatBytes(totalSize)}) [y/N]`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get delete confirmation message for a single item
|
|
123
|
+
*/
|
|
124
|
+
getDeleteMessageForItem(index: number): string {
|
|
125
|
+
const item = this.getItemAt(index)
|
|
126
|
+
if (!item) return "Delete item? [y/N]"
|
|
127
|
+
|
|
128
|
+
if (item.type === "project") {
|
|
129
|
+
const { project } = item
|
|
130
|
+
const dir = truncatePath(project.worktree, 35)
|
|
131
|
+
const sessions = `${project.sessionCount} sessions`
|
|
132
|
+
const size = formatBytes(project.totalSizeBytes)
|
|
133
|
+
return `Delete project "${dir}"? (${sessions}, ${size}) [y/N]`
|
|
134
|
+
} else {
|
|
135
|
+
const { session } = item
|
|
136
|
+
const title = session.title || session.slug || session.id.slice(0, 12)
|
|
137
|
+
const size = formatBytes(session.sizeBytes)
|
|
138
|
+
return `Delete session "${title}"? (${size}) [y/N]`
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Get the item at a specific index
|
|
144
|
+
*/
|
|
145
|
+
getItemAt(index: number): ListItem | undefined {
|
|
146
|
+
const items = this.getItems()
|
|
147
|
+
return items[index]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get the project ID for the item at a specific index
|
|
152
|
+
* Returns the project ID whether the item is a project or session
|
|
153
|
+
*/
|
|
154
|
+
getProjectIdAt(index: number): string | undefined {
|
|
155
|
+
const item = this.getItemAt(index)
|
|
156
|
+
if (!item) return undefined
|
|
157
|
+
return item.project.id
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Check if the item at index is a project (not a session)
|
|
162
|
+
*/
|
|
163
|
+
isProjectAt(index: number): boolean {
|
|
164
|
+
const item = this.getItemAt(index)
|
|
165
|
+
return item?.type === "project"
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
getTotalSize(indices: number[]): number {
|
|
169
|
+
const items = this.getItems()
|
|
170
|
+
return indices.reduce((sum, index) => {
|
|
171
|
+
const item = items[index]
|
|
172
|
+
if (!item) return sum
|
|
173
|
+
if (item.type === "project") {
|
|
174
|
+
return sum + item.project.totalSizeBytes
|
|
175
|
+
} else {
|
|
176
|
+
return sum + item.session.sizeBytes
|
|
177
|
+
}
|
|
178
|
+
}, 0)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async executeDelete(indices: number[]): Promise<DeleteResult> {
|
|
182
|
+
const items = this.getItems()
|
|
183
|
+
const selectedItems = indices.map((i) => items[i]).filter(Boolean)
|
|
184
|
+
|
|
185
|
+
let deletedCount = 0
|
|
186
|
+
let freedBytes = 0
|
|
187
|
+
const errors: string[] = []
|
|
188
|
+
|
|
189
|
+
// Separate projects and sessions
|
|
190
|
+
const projectsToDelete: ProjectInfo[] = []
|
|
191
|
+
const sessionsToDelete: SessionInfo[] = []
|
|
192
|
+
const deletedProjectIds = new Set<string>()
|
|
193
|
+
|
|
194
|
+
for (const item of selectedItems) {
|
|
195
|
+
if (item.type === "project") {
|
|
196
|
+
projectsToDelete.push(item.project)
|
|
197
|
+
deletedProjectIds.add(item.project.id)
|
|
198
|
+
} else {
|
|
199
|
+
// Only delete session if its parent project isn't being deleted
|
|
200
|
+
if (!deletedProjectIds.has(item.project.id)) {
|
|
201
|
+
sessionsToDelete.push(item.session)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Delete entire projects first
|
|
207
|
+
for (const project of projectsToDelete) {
|
|
208
|
+
const result = await deleteProject(project)
|
|
209
|
+
if (result.success) {
|
|
210
|
+
deletedCount++
|
|
211
|
+
freedBytes += result.bytesFreed
|
|
212
|
+
} else if (result.error) {
|
|
213
|
+
errors.push(`${project.worktree}: ${result.error}`)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Delete individual sessions
|
|
218
|
+
if (sessionsToDelete.length > 0) {
|
|
219
|
+
const results = await deleteSessions(sessionsToDelete)
|
|
220
|
+
|
|
221
|
+
for (const result of results) {
|
|
222
|
+
if (result.success) {
|
|
223
|
+
deletedCount++
|
|
224
|
+
freedBytes += result.bytesFreed
|
|
225
|
+
} else if (result.error) {
|
|
226
|
+
errors.push(`${result.sessionID}: ${result.error}`)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Check for empty projects to clean up
|
|
231
|
+
const projectIDs = new Set(sessionsToDelete.map((s) => s.projectID))
|
|
232
|
+
for (const session of sessionsToDelete) {
|
|
233
|
+
if (projectIDs.has(session.projectID)) {
|
|
234
|
+
await cleanupEmptyProject(session.projectID, session.projectWorktree)
|
|
235
|
+
projectIDs.delete(session.projectID)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { deletedCount, freedBytes, errors }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { SelectOption } from "@opentui/core"
|
|
2
|
+
import type { ProjectInfo, SessionInfo } from "../../types"
|
|
3
|
+
import type { StateManager } from "../state"
|
|
4
|
+
import { formatBytes, formatRelativeTime, truncatePath } from "../../utils"
|
|
5
|
+
import { deleteSessions, deleteProject, cleanupEmptyProject } from "../../data/manager"
|
|
6
|
+
import { BaseView, type ViewConfig, type DeleteResult } from "./base-view"
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// List Item Types (same structure as MainView)
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
export interface OrphanProjectItem {
|
|
13
|
+
type: "project"
|
|
14
|
+
project: ProjectInfo
|
|
15
|
+
projectIndex: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface OrphanSessionItem {
|
|
19
|
+
type: "session"
|
|
20
|
+
session: SessionInfo
|
|
21
|
+
project: ProjectInfo
|
|
22
|
+
projectIndex: number
|
|
23
|
+
sessionIndex: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type OrphanListItem = OrphanProjectItem | OrphanSessionItem
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Orphan List View - Tree view of orphan projects with sessions
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
export class OrphanListView extends BaseView {
|
|
33
|
+
readonly config: ViewConfig = {
|
|
34
|
+
id: "orphans",
|
|
35
|
+
title: "Orphans",
|
|
36
|
+
supportsSelectAll: false,
|
|
37
|
+
supportsDeleteAll: false,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
constructor(state: StateManager) {
|
|
41
|
+
super(state)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build flat list of items including orphan projects and their expanded sessions
|
|
46
|
+
*/
|
|
47
|
+
private buildItemList(): OrphanListItem[] {
|
|
48
|
+
const items: OrphanListItem[] = []
|
|
49
|
+
const orphanProjects = this.state.orphanProjects
|
|
50
|
+
|
|
51
|
+
for (let projectIndex = 0; projectIndex < orphanProjects.length; projectIndex++) {
|
|
52
|
+
const project = orphanProjects[projectIndex]
|
|
53
|
+
|
|
54
|
+
// Add project item
|
|
55
|
+
items.push({
|
|
56
|
+
type: "project",
|
|
57
|
+
project,
|
|
58
|
+
projectIndex,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// If expanded, add session items
|
|
62
|
+
if (this.state.isProjectExpanded(project.id)) {
|
|
63
|
+
for (let sessionIndex = 0; sessionIndex < project.sessions.length; sessionIndex++) {
|
|
64
|
+
const session = project.sessions[sessionIndex]
|
|
65
|
+
items.push({
|
|
66
|
+
type: "session",
|
|
67
|
+
session,
|
|
68
|
+
project,
|
|
69
|
+
projectIndex,
|
|
70
|
+
sessionIndex,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return items
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getItems(): OrphanListItem[] {
|
|
80
|
+
return this.buildItemList()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getOptions(_selectedIndices: Set<number>): SelectOption[] {
|
|
84
|
+
const items = this.getItems()
|
|
85
|
+
|
|
86
|
+
return items.map((item) => {
|
|
87
|
+
if (item.type === "project") {
|
|
88
|
+
const { project } = item
|
|
89
|
+
const expanded = this.state.isProjectExpanded(project.id)
|
|
90
|
+
const expandIcon = expanded ? "v" : ">"
|
|
91
|
+
const dir = truncatePath(project.worktree, 40)
|
|
92
|
+
const sessions = `${project.sessionCount} sessions`
|
|
93
|
+
const size = formatBytes(project.totalSizeBytes)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
name: `${expandIcon} ${dir} [ORPHAN]`,
|
|
97
|
+
description: `${sessions} | ${size}`,
|
|
98
|
+
value: `project:${project.id}`,
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
// Session item - indented
|
|
102
|
+
const { session } = item
|
|
103
|
+
const title = session.title || session.slug || session.id.slice(0, 12)
|
|
104
|
+
const updated = formatRelativeTime(session.time.updated)
|
|
105
|
+
const size = formatBytes(session.sizeBytes)
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
name: ` ${title}`,
|
|
109
|
+
description: ` ${updated} | ${size}`,
|
|
110
|
+
value: `session:${session.id}`,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
getDeleteMessage(count: number, totalSize: number): string {
|
|
117
|
+
return `Delete ${count} orphan item(s)? (${formatBytes(totalSize)}) [y/N]`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get delete confirmation message for a single item
|
|
122
|
+
*/
|
|
123
|
+
getDeleteMessageForItem(index: number): string {
|
|
124
|
+
const item = this.getItemAt(index)
|
|
125
|
+
if (!item) return "Delete item? [y/N]"
|
|
126
|
+
|
|
127
|
+
if (item.type === "project") {
|
|
128
|
+
const { project } = item
|
|
129
|
+
const dir = truncatePath(project.worktree, 35)
|
|
130
|
+
const sessions = `${project.sessionCount} sessions`
|
|
131
|
+
const size = formatBytes(project.totalSizeBytes)
|
|
132
|
+
return `Delete orphan project "${dir}"? (${sessions}, ${size}) [y/N]`
|
|
133
|
+
} else {
|
|
134
|
+
const { session } = item
|
|
135
|
+
const title = session.title || session.slug || session.id.slice(0, 12)
|
|
136
|
+
const size = formatBytes(session.sizeBytes)
|
|
137
|
+
return `Delete session "${title}"? (${size}) [y/N]`
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get the item at a specific index
|
|
143
|
+
*/
|
|
144
|
+
getItemAt(index: number): OrphanListItem | undefined {
|
|
145
|
+
const items = this.getItems()
|
|
146
|
+
return items[index]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Get the project ID for the item at a specific index
|
|
151
|
+
* Returns the project ID whether the item is a project or session
|
|
152
|
+
*/
|
|
153
|
+
getProjectIdAt(index: number): string | undefined {
|
|
154
|
+
const item = this.getItemAt(index)
|
|
155
|
+
if (!item) return undefined
|
|
156
|
+
return item.project.id
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check if the item at index is a project (not a session)
|
|
161
|
+
*/
|
|
162
|
+
isProjectAt(index: number): boolean {
|
|
163
|
+
const item = this.getItemAt(index)
|
|
164
|
+
return item?.type === "project"
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
getTotalSize(indices: number[]): number {
|
|
168
|
+
const items = this.getItems()
|
|
169
|
+
return indices.reduce((sum, index) => {
|
|
170
|
+
const item = items[index]
|
|
171
|
+
if (!item) return sum
|
|
172
|
+
if (item.type === "project") {
|
|
173
|
+
return sum + item.project.totalSizeBytes
|
|
174
|
+
} else {
|
|
175
|
+
return sum + item.session.sizeBytes
|
|
176
|
+
}
|
|
177
|
+
}, 0)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async executeDelete(indices: number[]): Promise<DeleteResult> {
|
|
181
|
+
const items = this.getItems()
|
|
182
|
+
const selectedItems = indices.map((i) => items[i]).filter(Boolean)
|
|
183
|
+
|
|
184
|
+
let deletedCount = 0
|
|
185
|
+
let freedBytes = 0
|
|
186
|
+
const errors: string[] = []
|
|
187
|
+
|
|
188
|
+
// Separate projects and sessions
|
|
189
|
+
const projectsToDelete: ProjectInfo[] = []
|
|
190
|
+
const sessionsToDelete: SessionInfo[] = []
|
|
191
|
+
const deletedProjectIds = new Set<string>()
|
|
192
|
+
|
|
193
|
+
for (const item of selectedItems) {
|
|
194
|
+
if (item.type === "project") {
|
|
195
|
+
projectsToDelete.push(item.project)
|
|
196
|
+
deletedProjectIds.add(item.project.id)
|
|
197
|
+
} else {
|
|
198
|
+
// Only delete session if its parent project isn't being deleted
|
|
199
|
+
if (!deletedProjectIds.has(item.project.id)) {
|
|
200
|
+
sessionsToDelete.push(item.session)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Delete entire projects first
|
|
206
|
+
for (const project of projectsToDelete) {
|
|
207
|
+
const result = await deleteProject(project)
|
|
208
|
+
if (result.success) {
|
|
209
|
+
deletedCount++
|
|
210
|
+
freedBytes += result.bytesFreed
|
|
211
|
+
} else if (result.error) {
|
|
212
|
+
errors.push(`${project.worktree}: ${result.error}`)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Delete individual sessions
|
|
217
|
+
if (sessionsToDelete.length > 0) {
|
|
218
|
+
const results = await deleteSessions(sessionsToDelete)
|
|
219
|
+
|
|
220
|
+
for (const result of results) {
|
|
221
|
+
if (result.success) {
|
|
222
|
+
deletedCount++
|
|
223
|
+
freedBytes += result.bytesFreed
|
|
224
|
+
} else if (result.error) {
|
|
225
|
+
errors.push(`${result.sessionID}: ${result.error}`)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Check for empty projects to clean up
|
|
230
|
+
const projectIDs = new Set(sessionsToDelete.map((s) => s.projectID))
|
|
231
|
+
for (const session of sessionsToDelete) {
|
|
232
|
+
if (projectIDs.has(session.projectID)) {
|
|
233
|
+
await cleanupEmptyProject(session.projectID, session.projectWorktree)
|
|
234
|
+
projectIDs.delete(session.projectID)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { deletedCount, freedBytes, errors }
|
|
240
|
+
}
|
|
241
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Size Formatting
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
export function formatBytes(bytes: number): string {
|
|
6
|
+
if (bytes === 0) return "0 B"
|
|
7
|
+
|
|
8
|
+
const units = ["B", "KB", "MB", "GB", "TB"]
|
|
9
|
+
const k = 1024
|
|
10
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
11
|
+
const value = bytes / Math.pow(k, i)
|
|
12
|
+
|
|
13
|
+
// Show 1 decimal place for values < 10, otherwise round
|
|
14
|
+
if (value < 10 && i > 0) {
|
|
15
|
+
return `${value.toFixed(1)} ${units[i]}`
|
|
16
|
+
}
|
|
17
|
+
return `${Math.round(value)} ${units[i]}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Time Formatting
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
export function formatRelativeTime(timestamp: number): string {
|
|
25
|
+
const now = Date.now()
|
|
26
|
+
const diff = now - timestamp
|
|
27
|
+
|
|
28
|
+
const seconds = Math.floor(diff / 1000)
|
|
29
|
+
const minutes = Math.floor(seconds / 60)
|
|
30
|
+
const hours = Math.floor(minutes / 60)
|
|
31
|
+
const days = Math.floor(hours / 24)
|
|
32
|
+
const weeks = Math.floor(days / 7)
|
|
33
|
+
const months = Math.floor(days / 30)
|
|
34
|
+
const years = Math.floor(days / 365)
|
|
35
|
+
|
|
36
|
+
if (years > 0) return `${years}y ago`
|
|
37
|
+
if (months > 0) return `${months}mo ago`
|
|
38
|
+
if (weeks > 0) return `${weeks}w ago`
|
|
39
|
+
if (days > 0) return `${days}d ago`
|
|
40
|
+
if (hours > 0) return `${hours}h ago`
|
|
41
|
+
if (minutes > 0) return `${minutes}m ago`
|
|
42
|
+
return "just now"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatDate(date: Date): string {
|
|
46
|
+
const year = date.getFullYear()
|
|
47
|
+
const month = String(date.getMonth() + 1).padStart(2, "0")
|
|
48
|
+
const day = String(date.getDate()).padStart(2, "0")
|
|
49
|
+
const hour = String(date.getHours()).padStart(2, "0")
|
|
50
|
+
const minute = String(date.getMinutes()).padStart(2, "0")
|
|
51
|
+
return `${year}-${month}-${day} ${hour}:${minute}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// Path Formatting
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
export function truncatePath(path: string, maxLength: number): string {
|
|
59
|
+
if (path.length <= maxLength) return path
|
|
60
|
+
|
|
61
|
+
// Replace home directory with ~
|
|
62
|
+
const home = process.env.HOME || ""
|
|
63
|
+
let displayPath = path
|
|
64
|
+
if (home && path.startsWith(home)) {
|
|
65
|
+
displayPath = "~" + path.slice(home.length)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (displayPath.length <= maxLength) return displayPath
|
|
69
|
+
|
|
70
|
+
// Truncate from the middle
|
|
71
|
+
const ellipsis = "..."
|
|
72
|
+
const availableLength = maxLength - ellipsis.length
|
|
73
|
+
const startLength = Math.ceil(availableLength / 2)
|
|
74
|
+
const endLength = Math.floor(availableLength / 2)
|
|
75
|
+
|
|
76
|
+
return displayPath.slice(0, startLength) + ellipsis + displayPath.slice(-endLength)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ============================================================================
|
|
80
|
+
// String Padding
|
|
81
|
+
// ============================================================================
|
|
82
|
+
|
|
83
|
+
export function padRight(str: string, length: number): string {
|
|
84
|
+
if (str.length >= length) return str.slice(0, length)
|
|
85
|
+
return str + " ".repeat(length - str.length)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function padLeft(str: string, length: number): string {
|
|
89
|
+
if (str.length >= length) return str.slice(0, length)
|
|
90
|
+
return " ".repeat(length - str.length) + str
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// Summary Formatting
|
|
95
|
+
// ============================================================================
|
|
96
|
+
|
|
97
|
+
export function formatDeleteSummary(
|
|
98
|
+
sessionsDeleted: number,
|
|
99
|
+
bytesFreed: number
|
|
100
|
+
): string {
|
|
101
|
+
const sessionWord = sessionsDeleted === 1 ? "session" : "sessions"
|
|
102
|
+
return `Deleted ${sessionsDeleted} ${sessionWord}, freed ${formatBytes(bytesFreed)}`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function formatProjectDeleteSummary(
|
|
106
|
+
projectID: string,
|
|
107
|
+
sessionsDeleted: number,
|
|
108
|
+
bytesFreed: number,
|
|
109
|
+
frecencyRemoved: number
|
|
110
|
+
): string {
|
|
111
|
+
const parts = [`Deleted project ${projectID.slice(0, 8)}...`]
|
|
112
|
+
parts.push(`${sessionsDeleted} sessions`)
|
|
113
|
+
parts.push(`freed ${formatBytes(bytesFreed)}`)
|
|
114
|
+
if (frecencyRemoved > 0) {
|
|
115
|
+
parts.push(`${frecencyRemoved} frecency entries`)
|
|
116
|
+
}
|
|
117
|
+
return parts.join(", ")
|
|
118
|
+
}
|