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,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
+ }