@elefunc/send 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/README.md +57 -0
- package/package.json +52 -0
- package/patches/@rezi-ui%2Fcore@0.1.0-alpha.60.patch +227 -0
- package/patches/werift@0.22.9.patch +31 -0
- package/src/core/files.ts +79 -0
- package/src/core/paths.ts +19 -0
- package/src/core/protocol.ts +241 -0
- package/src/core/session.ts +1435 -0
- package/src/core/targeting.ts +39 -0
- package/src/index.ts +283 -0
- package/src/tui/app.ts +1442 -0
- package/src/tui/file-search-protocol.ts +48 -0
- package/src/tui/file-search.ts +282 -0
- package/src/tui/file-search.worker.ts +127 -0
- package/src/tui/rezi-checkbox-click.ts +63 -0
- package/src/types/bun-runtime.d.ts +5 -0
- package/src/types/bun-test.d.ts +9 -0
package/src/tui/app.ts
ADDED
|
@@ -0,0 +1,1442 @@
|
|
|
1
|
+
import { rgb, ui, type BadgeVariant, type VNode } from "@rezi-ui/core"
|
|
2
|
+
import { createNodeApp } from "@rezi-ui/node"
|
|
3
|
+
import { inspectLocalFile } from "../core/files"
|
|
4
|
+
import { SendSession, type PeerSnapshot, type SessionConfig, type SessionSnapshot, type TransferSnapshot } from "../core/session"
|
|
5
|
+
import { cleanLocalId, cleanName, cleanRoom, displayPeerName, fallbackName, formatBytes, type LogEntry, peerDefaultsToken, type PeerProfile, uid } from "../core/protocol"
|
|
6
|
+
import { FILE_SEARCH_VISIBLE_ROWS, type FileSearchEvent, type FileSearchMatch, type FileSearchRequest } from "./file-search-protocol"
|
|
7
|
+
import { deriveFileSearchScope, formatFileSearchDisplayPath, normalizeSearchQuery, offsetFileSearchMatchIndices } from "./file-search"
|
|
8
|
+
import { installCheckboxClickPatch } from "./rezi-checkbox-click"
|
|
9
|
+
|
|
10
|
+
type Notice = { text: string; variant: "info" | "success" | "warning" | "error" }
|
|
11
|
+
type DraftItem = { id: string; path: string; name: string; size: number; createdAt: number }
|
|
12
|
+
type SessionSeed = Omit<SessionConfig, "autoAcceptIncoming" | "autoSaveIncoming"> & { localId: string; name: string; room: string }
|
|
13
|
+
type TuiAction = () => void
|
|
14
|
+
type TransferSection = { title: string; items: TransferSnapshot[]; clearAction?: "completed" | "failed" }
|
|
15
|
+
type TransferSummaryStat = { state: string; label?: string; count: number; size: number; countText?: string; sizeText?: string }
|
|
16
|
+
type TransferGroup = { key: string; name: string; items: TransferSnapshot[] }
|
|
17
|
+
type DenseSectionChild = VNode | false | null | undefined
|
|
18
|
+
type FilePreviewState = {
|
|
19
|
+
dismissedQuery: string | null
|
|
20
|
+
workspaceRoot: string | null
|
|
21
|
+
displayPrefix: string
|
|
22
|
+
displayQuery: string | null
|
|
23
|
+
pendingQuery: string | null
|
|
24
|
+
waiting: boolean
|
|
25
|
+
error: string | null
|
|
26
|
+
results: FileSearchMatch[]
|
|
27
|
+
selectedIndex: number | null
|
|
28
|
+
scrollTop: number
|
|
29
|
+
}
|
|
30
|
+
type DenseSectionOptions = {
|
|
31
|
+
id?: string
|
|
32
|
+
key?: string
|
|
33
|
+
title?: string
|
|
34
|
+
titleNode?: VNode
|
|
35
|
+
subtitle?: string
|
|
36
|
+
actions?: readonly VNode[]
|
|
37
|
+
border?: "rounded" | "single" | "none"
|
|
38
|
+
flex?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type VisiblePane = "peers" | "transfers" | "logs"
|
|
42
|
+
|
|
43
|
+
export interface TuiState {
|
|
44
|
+
session: SendSession
|
|
45
|
+
sessionSeed: SessionSeed
|
|
46
|
+
snapshot: SessionSnapshot
|
|
47
|
+
focusedId: string | null
|
|
48
|
+
roomInput: string
|
|
49
|
+
nameInput: string
|
|
50
|
+
pendingFocusTarget: string | null
|
|
51
|
+
focusRequestEpoch: number
|
|
52
|
+
bootNameJumpPending: boolean
|
|
53
|
+
draftInput: string
|
|
54
|
+
draftInputKeyVersion: number
|
|
55
|
+
filePreview: FilePreviewState
|
|
56
|
+
drafts: DraftItem[]
|
|
57
|
+
autoOfferOutgoing: boolean
|
|
58
|
+
autoAcceptIncoming: boolean
|
|
59
|
+
autoSaveIncoming: boolean
|
|
60
|
+
hideTerminalPeers: boolean
|
|
61
|
+
eventsExpanded: boolean
|
|
62
|
+
offeringDrafts: boolean
|
|
63
|
+
notice: Notice
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface TuiActions {
|
|
67
|
+
toggleEvents: TuiAction
|
|
68
|
+
jumpToRandomRoom: TuiAction
|
|
69
|
+
commitRoom: TuiAction
|
|
70
|
+
setRoomInput: (value: string) => void
|
|
71
|
+
jumpToNewSelf: TuiAction
|
|
72
|
+
commitName: TuiAction
|
|
73
|
+
setNameInput: (value: string) => void
|
|
74
|
+
toggleSelectReadyPeers: TuiAction
|
|
75
|
+
clearPeerSelection: TuiAction
|
|
76
|
+
toggleHideTerminalPeers: TuiAction
|
|
77
|
+
togglePeer: (peerId: string) => void
|
|
78
|
+
toggleAutoOffer: TuiAction
|
|
79
|
+
toggleAutoAccept: TuiAction
|
|
80
|
+
toggleAutoSave: TuiAction
|
|
81
|
+
setDraftInput: (value: string) => void
|
|
82
|
+
addDrafts: TuiAction
|
|
83
|
+
removeDraft: (draftId: string) => void
|
|
84
|
+
clearDrafts: TuiAction
|
|
85
|
+
cancelPendingOffers: TuiAction
|
|
86
|
+
acceptTransfer: (transferId: string) => void
|
|
87
|
+
rejectTransfer: (transferId: string) => void
|
|
88
|
+
cancelTransfer: (transferId: string) => void
|
|
89
|
+
saveTransfer: (transferId: string) => void
|
|
90
|
+
clearCompleted: TuiAction
|
|
91
|
+
clearFailed: TuiAction
|
|
92
|
+
clearLogs: TuiAction
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ROOM_INPUT_ID = "room-input"
|
|
96
|
+
const NAME_INPUT_ID = "name-input"
|
|
97
|
+
const DRAFT_INPUT_ID = "draft-input"
|
|
98
|
+
const TRANSPARENT_BORDER_STYLE = { fg: rgb(7, 10, 12) } as const
|
|
99
|
+
const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
|
|
100
|
+
|
|
101
|
+
const countFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
|
|
102
|
+
const percentFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
|
|
103
|
+
const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
|
104
|
+
const pluralRules = new Intl.PluralRules()
|
|
105
|
+
|
|
106
|
+
export const visiblePanes = (showEvents: boolean): VisiblePane[] => showEvents ? ["peers", "transfers", "logs"] : ["peers", "transfers"]
|
|
107
|
+
|
|
108
|
+
const noop = () => {}
|
|
109
|
+
|
|
110
|
+
export const createNoopTuiActions = (): TuiActions => ({
|
|
111
|
+
toggleEvents: noop,
|
|
112
|
+
jumpToRandomRoom: noop,
|
|
113
|
+
commitRoom: noop,
|
|
114
|
+
setRoomInput: noop,
|
|
115
|
+
jumpToNewSelf: noop,
|
|
116
|
+
commitName: noop,
|
|
117
|
+
setNameInput: noop,
|
|
118
|
+
toggleSelectReadyPeers: noop,
|
|
119
|
+
clearPeerSelection: noop,
|
|
120
|
+
toggleHideTerminalPeers: noop,
|
|
121
|
+
togglePeer: noop,
|
|
122
|
+
toggleAutoOffer: noop,
|
|
123
|
+
toggleAutoAccept: noop,
|
|
124
|
+
toggleAutoSave: noop,
|
|
125
|
+
setDraftInput: noop,
|
|
126
|
+
addDrafts: noop,
|
|
127
|
+
removeDraft: noop,
|
|
128
|
+
clearDrafts: noop,
|
|
129
|
+
cancelPendingOffers: noop,
|
|
130
|
+
acceptTransfer: noop,
|
|
131
|
+
rejectTransfer: noop,
|
|
132
|
+
cancelTransfer: noop,
|
|
133
|
+
saveTransfer: noop,
|
|
134
|
+
clearCompleted: noop,
|
|
135
|
+
clearFailed: noop,
|
|
136
|
+
clearLogs: noop,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const plural = (count: number, noun: string) => `${countFormat.format(count)} ${noun}${pluralRules.select(count) === "one" ? "" : "s"}`
|
|
140
|
+
const shortText = (value: unknown, max = 88) => {
|
|
141
|
+
const text = typeof value === "string" ? value : JSON.stringify(value)
|
|
142
|
+
return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
|
|
143
|
+
}
|
|
144
|
+
const toggleIntent = (active: boolean) => active ? "success" : "secondary"
|
|
145
|
+
const statusKind = (socketState: SessionSnapshot["socketState"]) => socketState === "open" ? "online" : socketState === "connecting" ? "busy" : socketState === "error" ? "offline" : "away"
|
|
146
|
+
const peerConnectionStatusKind = (status: string) => ({
|
|
147
|
+
connected: "online",
|
|
148
|
+
connecting: "busy",
|
|
149
|
+
disconnected: "away",
|
|
150
|
+
left: "away",
|
|
151
|
+
failed: "offline",
|
|
152
|
+
closed: "offline",
|
|
153
|
+
idle: "unknown",
|
|
154
|
+
new: "unknown",
|
|
155
|
+
}[status] || "unknown") as "online" | "offline" | "away" | "busy" | "unknown"
|
|
156
|
+
const visiblePeers = (peers: PeerSnapshot[], hideTerminalPeers: boolean) => hideTerminalPeers ? peers.filter(peer => peer.presence === "active") : peers
|
|
157
|
+
const transferProgress = (transfer: TransferSnapshot) => Math.max(0, Math.min(1, transfer.progress / 100))
|
|
158
|
+
const isPendingOffer = (transfer: TransferSnapshot) => transfer.direction === "out" && (transfer.status === "queued" || transfer.status === "offered")
|
|
159
|
+
const statusVariant = (status: TransferSnapshot["status"]): BadgeVariant => ({
|
|
160
|
+
draft: "default",
|
|
161
|
+
complete: "success",
|
|
162
|
+
sending: "info",
|
|
163
|
+
receiving: "info",
|
|
164
|
+
accepted: "warning",
|
|
165
|
+
queued: "warning",
|
|
166
|
+
offered: "warning",
|
|
167
|
+
pending: "warning",
|
|
168
|
+
rejected: "error",
|
|
169
|
+
cancelled: "error",
|
|
170
|
+
error: "error",
|
|
171
|
+
cancelling: "warning",
|
|
172
|
+
"awaiting-done": "info",
|
|
173
|
+
}[status] || "default") as BadgeVariant
|
|
174
|
+
const statusToneVariant = (value: string): BadgeVariant => ({
|
|
175
|
+
open: "success",
|
|
176
|
+
connected: "success",
|
|
177
|
+
complete: "success",
|
|
178
|
+
accepted: "success",
|
|
179
|
+
receiving: "success",
|
|
180
|
+
sending: "success",
|
|
181
|
+
available: "success",
|
|
182
|
+
used: "success",
|
|
183
|
+
idle: "info",
|
|
184
|
+
connecting: "info",
|
|
185
|
+
offered: "info",
|
|
186
|
+
"awaiting-done": "info",
|
|
187
|
+
queued: "info",
|
|
188
|
+
retrying: "info",
|
|
189
|
+
pending: "warning",
|
|
190
|
+
cancelling: "warning",
|
|
191
|
+
checking: "warning",
|
|
192
|
+
disconnected: "warning",
|
|
193
|
+
left: "warning",
|
|
194
|
+
rejected: "warning",
|
|
195
|
+
cancelled: "warning",
|
|
196
|
+
absent: "warning",
|
|
197
|
+
none: "warning",
|
|
198
|
+
error: "error",
|
|
199
|
+
failed: "error",
|
|
200
|
+
closed: "error",
|
|
201
|
+
}[value] || "default") as BadgeVariant
|
|
202
|
+
const joinSummary = (parts: Array<string | undefined>, glue = ", ") => parts.filter(Boolean).join(glue)
|
|
203
|
+
const geoSummary = (profile?: PeerProfile) => joinSummary([profile?.geo?.city, profile?.geo?.region, profile?.geo?.country]) || "—"
|
|
204
|
+
const netSummary = (profile?: PeerProfile) => joinSummary([profile?.network?.asOrganization, profile?.network?.colo], " · ") || "—"
|
|
205
|
+
const uaSummary = (profile?: PeerProfile) => joinSummary([profile?.ua?.browser, profile?.ua?.os, profile?.ua?.device && profile.ua.device !== "desktop" ? profile.ua.device : ""] , " · ") || "—"
|
|
206
|
+
const profileIp = (profile?: PeerProfile) => profile?.network?.ip || "—"
|
|
207
|
+
const peerDefaultsVariant = (profile?: PeerProfile): BadgeVariant => {
|
|
208
|
+
const token = peerDefaultsToken(profile)
|
|
209
|
+
return token === "AS" ? "success" : token === "as" ? "warning" : token === "??" ? "default" : "info"
|
|
210
|
+
}
|
|
211
|
+
const TIGHT_TAG_COLORS = {
|
|
212
|
+
default: rgb(89, 194, 255),
|
|
213
|
+
success: rgb(170, 217, 76),
|
|
214
|
+
warning: rgb(242, 169, 59),
|
|
215
|
+
error: rgb(240, 113, 120),
|
|
216
|
+
info: rgb(89, 194, 255),
|
|
217
|
+
} as const
|
|
218
|
+
const tightTag = (text: string, props: { key?: string; variant?: BadgeVariant; bare?: boolean } = {}) => ui.text(props.bare ? text : `(${text})`, {
|
|
219
|
+
...(props.key === undefined ? {} : { key: props.key }),
|
|
220
|
+
style: {
|
|
221
|
+
fg: TIGHT_TAG_COLORS[props.variant ?? "default"],
|
|
222
|
+
bold: true,
|
|
223
|
+
},
|
|
224
|
+
})
|
|
225
|
+
const emptyFilePreviewState = (): FilePreviewState => ({
|
|
226
|
+
dismissedQuery: null,
|
|
227
|
+
workspaceRoot: null,
|
|
228
|
+
displayPrefix: "",
|
|
229
|
+
displayQuery: null,
|
|
230
|
+
pendingQuery: null,
|
|
231
|
+
waiting: false,
|
|
232
|
+
error: null,
|
|
233
|
+
results: [],
|
|
234
|
+
selectedIndex: null,
|
|
235
|
+
scrollTop: 0,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
type FocusControllerState = Pick<TuiState, "pendingFocusTarget" | "focusRequestEpoch" | "bootNameJumpPending">
|
|
239
|
+
|
|
240
|
+
export const deriveBootFocusState = (name: string, focusRequestEpoch = 0): FocusControllerState => {
|
|
241
|
+
const normalizedName = cleanName(name)
|
|
242
|
+
const customSelfName = normalizedName !== fallbackName
|
|
243
|
+
return {
|
|
244
|
+
pendingFocusTarget: customSelfName ? DRAFT_INPUT_ID : NAME_INPUT_ID,
|
|
245
|
+
focusRequestEpoch,
|
|
246
|
+
bootNameJumpPending: !customSelfName,
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export const consumeSatisfiedFocusRequest = <T extends FocusControllerState>(state: T, focusedId: string | null): T =>
|
|
251
|
+
state.pendingFocusTarget !== null && state.pendingFocusTarget === focusedId
|
|
252
|
+
? { ...state, pendingFocusTarget: null }
|
|
253
|
+
: state
|
|
254
|
+
|
|
255
|
+
export const scheduleBootNameJump = <T extends FocusControllerState>(state: T): T =>
|
|
256
|
+
state.bootNameJumpPending
|
|
257
|
+
? {
|
|
258
|
+
...state,
|
|
259
|
+
pendingFocusTarget: DRAFT_INPUT_ID,
|
|
260
|
+
focusRequestEpoch: state.focusRequestEpoch + 1,
|
|
261
|
+
bootNameJumpPending: false,
|
|
262
|
+
}
|
|
263
|
+
: state
|
|
264
|
+
|
|
265
|
+
const visibleNameInput = (name: string) => cleanName(name) === fallbackName ? "" : cleanName(name)
|
|
266
|
+
|
|
267
|
+
const normalizeSessionSeed = (config: SessionConfig): SessionSeed => ({
|
|
268
|
+
...config,
|
|
269
|
+
localId: cleanLocalId(config.localId ?? uid(8)),
|
|
270
|
+
name: cleanName(config.name ?? fallbackName),
|
|
271
|
+
room: cleanRoom(config.room),
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
const makeSession = (seed: SessionSeed, autoAcceptIncoming: boolean, autoSaveIncoming: boolean) => new SendSession({
|
|
275
|
+
...seed,
|
|
276
|
+
autoAcceptIncoming,
|
|
277
|
+
autoSaveIncoming,
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
const transferSections = (snapshot: SessionSnapshot): TransferSection[] => {
|
|
281
|
+
const pending = snapshot.transfers.filter(transfer => transfer.direction === "in" && transfer.status === "pending" || transfer.direction === "out" && (transfer.status === "queued" || transfer.status === "offered"))
|
|
282
|
+
const active = snapshot.transfers.filter(transfer => !["pending", "queued", "offered", "complete", "rejected", "cancelled", "error"].includes(transfer.status))
|
|
283
|
+
const completed = snapshot.transfers.filter(transfer => transfer.status === "complete")
|
|
284
|
+
const failed = snapshot.transfers.filter(transfer => ["rejected", "cancelled", "error"].includes(transfer.status))
|
|
285
|
+
return [
|
|
286
|
+
{ title: "Pending", items: pending },
|
|
287
|
+
{ title: "Transfers", items: active },
|
|
288
|
+
{ title: "Completed", items: completed, clearAction: "completed" },
|
|
289
|
+
{ title: "Failed", items: failed, clearAction: "failed" },
|
|
290
|
+
]
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const formatClockTime = (value: number) => value ? timeFormat.format(new Date(value)) : "—"
|
|
294
|
+
|
|
295
|
+
export const formatDuration = (value: number) => {
|
|
296
|
+
const ms = Number(value) || 0
|
|
297
|
+
const total = Math.round(ms / 1000)
|
|
298
|
+
if (!Number.isFinite(ms) || ms <= 0) return "—"
|
|
299
|
+
if (ms < 1000) return "<1s"
|
|
300
|
+
if (total < 60) return `${total}s`
|
|
301
|
+
const hours = Math.floor(total / 3600)
|
|
302
|
+
const minutes = Math.floor(total % 3600 / 60)
|
|
303
|
+
const seconds = total % 60
|
|
304
|
+
return hours ? `${hours}h ${`${minutes}`.padStart(2, "0")}m` : `${minutes}m ${`${seconds}`.padStart(2, "0")}s`
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export const transferActualDurationMs = (transfer: TransferSnapshot, now = Date.now()) => transfer.startedAt ? Math.max(0, (transfer.endedAt || transfer.updatedAt || now) - transfer.startedAt) : Number.NaN
|
|
308
|
+
export const transferWaitDurationMs = (transfer: TransferSnapshot, now = Date.now()) => Math.max(0, (transfer.startedAt || now) - transfer.createdAt)
|
|
309
|
+
export const filePreviewVisible = (state: Pick<TuiState, "focusedId" | "draftInput" | "filePreview">) =>
|
|
310
|
+
state.focusedId === DRAFT_INPUT_ID && !!normalizeSearchQuery(state.draftInput) && state.filePreview.dismissedQuery !== state.draftInput
|
|
311
|
+
export const clampFilePreviewSelectedIndex = (selectedIndex: number | null, resultCount: number) =>
|
|
312
|
+
!resultCount ? null : selectedIndex == null ? 0 : Math.max(0, Math.min(resultCount - 1, selectedIndex))
|
|
313
|
+
export const ensureFilePreviewScrollTop = (selectedIndex: number | null, scrollTop: number, resultCount: number, visibleRows = FILE_SEARCH_VISIBLE_ROWS) => {
|
|
314
|
+
if (!resultCount || selectedIndex == null) return 0
|
|
315
|
+
const maxScrollTop = Math.max(0, resultCount - visibleRows)
|
|
316
|
+
if (selectedIndex < scrollTop) return selectedIndex
|
|
317
|
+
if (selectedIndex >= scrollTop + visibleRows) return Math.min(maxScrollTop, selectedIndex - visibleRows + 1)
|
|
318
|
+
return Math.max(0, Math.min(maxScrollTop, scrollTop))
|
|
319
|
+
}
|
|
320
|
+
export const moveFilePreviewSelection = (preview: FilePreviewState, direction: -1 | 1) => {
|
|
321
|
+
if (!preview.results.length) return preview
|
|
322
|
+
const nextIndex = preview.selectedIndex == null
|
|
323
|
+
? direction > 0 ? 0 : preview.results.length - 1
|
|
324
|
+
: (preview.selectedIndex + direction + preview.results.length) % preview.results.length
|
|
325
|
+
return {
|
|
326
|
+
...preview,
|
|
327
|
+
selectedIndex: nextIndex,
|
|
328
|
+
scrollTop: ensureFilePreviewScrollTop(nextIndex, preview.scrollTop, preview.results.length),
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const visibleFilePreviewResults = (preview: FilePreviewState) => preview.results.slice(preview.scrollTop, preview.scrollTop + FILE_SEARCH_VISIBLE_ROWS)
|
|
333
|
+
const selectedFilePreviewMatch = (state: TuiState) => {
|
|
334
|
+
const index = state.filePreview.selectedIndex
|
|
335
|
+
return index == null ? null : state.filePreview.results[index] ?? null
|
|
336
|
+
}
|
|
337
|
+
const highlightedSegments = (value: string, indices: number[]) => {
|
|
338
|
+
const marks = new Set(indices)
|
|
339
|
+
const chars = Array.from(value)
|
|
340
|
+
const segments: Array<{ text: string; highlighted: boolean }> = []
|
|
341
|
+
let current = ""
|
|
342
|
+
let highlighted = false
|
|
343
|
+
for (let index = 0; index < chars.length; index += 1) {
|
|
344
|
+
const nextHighlighted = marks.has(index)
|
|
345
|
+
if (current && nextHighlighted !== highlighted) {
|
|
346
|
+
segments.push({ text: current, highlighted })
|
|
347
|
+
current = ""
|
|
348
|
+
}
|
|
349
|
+
current += chars[index]
|
|
350
|
+
highlighted = nextHighlighted
|
|
351
|
+
}
|
|
352
|
+
if (current) segments.push({ text: current, highlighted })
|
|
353
|
+
return segments
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export const summarizeStates = <T,>(items: T[], stateOf: (item: T) => string = item => `${(item as { status?: string }).status ?? "idle"}`, sizeOf: (item: T) => number = item => Number((item as { size?: number }).size) || 0, defaults: string[] = []): TransferSummaryStat[] => {
|
|
357
|
+
const order = ["draft", "pending", "queued", "offered", "accepted", "receiving", "sending", "awaiting-done", "cancelling", "complete", "rejected", "cancelled", "error"]
|
|
358
|
+
const buckets = new Map(defaults.map(state => [state, { state, count: 0, size: 0 } satisfies TransferSummaryStat]))
|
|
359
|
+
for (const item of items) {
|
|
360
|
+
const state = stateOf(item) || "idle"
|
|
361
|
+
const size = Number(sizeOf(item)) || 0
|
|
362
|
+
if (!buckets.has(state)) buckets.set(state, { state, count: 0, size: 0 })
|
|
363
|
+
const bucket = buckets.get(state)!
|
|
364
|
+
bucket.count += 1
|
|
365
|
+
bucket.size += size
|
|
366
|
+
}
|
|
367
|
+
return [...buckets.values()].sort((left, right) => {
|
|
368
|
+
const leftIndex = order.indexOf(left.state)
|
|
369
|
+
const rightIndex = order.indexOf(right.state)
|
|
370
|
+
return (leftIndex < 0 ? order.length : leftIndex) - (rightIndex < 0 ? order.length : rightIndex) || left.state.localeCompare(right.state)
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export const transferSummaryStats = (items: TransferSnapshot[], now = Date.now()): TransferSummaryStat[] => {
|
|
375
|
+
let totalDuration = 0
|
|
376
|
+
let durationCount = 0
|
|
377
|
+
for (const transfer of items) {
|
|
378
|
+
const duration = transferActualDurationMs(transfer, now)
|
|
379
|
+
if (!Number.isFinite(duration)) continue
|
|
380
|
+
totalDuration += duration
|
|
381
|
+
durationCount += 1
|
|
382
|
+
}
|
|
383
|
+
return [
|
|
384
|
+
...summarizeStates(items),
|
|
385
|
+
{ state: "duration", label: "duration", count: durationCount, size: 0, countText: durationCount ? formatDuration(totalDuration) : "—", sizeText: "" },
|
|
386
|
+
]
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export const groupTransfersByPeer = (transfers: TransferSnapshot[], peers: PeerSnapshot[]): TransferGroup[] => {
|
|
390
|
+
const peersById = new Map(peers.map(peer => [peer.id, peer] as const))
|
|
391
|
+
const groups = new Map<string, TransferGroup>()
|
|
392
|
+
for (const transfer of transfers) {
|
|
393
|
+
const key = transfer.peerId || `${transfer.direction}:${transfer.peerName || transfer.id}`
|
|
394
|
+
if (!groups.has(key)) {
|
|
395
|
+
const peer = peersById.get(transfer.peerId)
|
|
396
|
+
groups.set(key, {
|
|
397
|
+
key,
|
|
398
|
+
name: peer?.displayName ?? displayPeerName(transfer.peerName || fallbackName, transfer.peerId),
|
|
399
|
+
items: [],
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
groups.get(key)!.items.push(transfer)
|
|
403
|
+
}
|
|
404
|
+
return [...groups.values()]
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export const createInitialTuiState = (initialConfig: SessionConfig, showEvents = false): TuiState => {
|
|
408
|
+
const sessionSeed = normalizeSessionSeed(initialConfig)
|
|
409
|
+
const autoAcceptIncoming = initialConfig.autoAcceptIncoming ?? true
|
|
410
|
+
const autoSaveIncoming = initialConfig.autoSaveIncoming ?? true
|
|
411
|
+
const session = makeSession(sessionSeed, autoAcceptIncoming, autoSaveIncoming)
|
|
412
|
+
const focusState = deriveBootFocusState(sessionSeed.name)
|
|
413
|
+
return {
|
|
414
|
+
session,
|
|
415
|
+
sessionSeed,
|
|
416
|
+
snapshot: session.snapshot(),
|
|
417
|
+
focusedId: null,
|
|
418
|
+
roomInput: sessionSeed.room,
|
|
419
|
+
nameInput: visibleNameInput(sessionSeed.name),
|
|
420
|
+
...focusState,
|
|
421
|
+
draftInput: "",
|
|
422
|
+
draftInputKeyVersion: 0,
|
|
423
|
+
filePreview: emptyFilePreviewState(),
|
|
424
|
+
drafts: [],
|
|
425
|
+
autoOfferOutgoing: true,
|
|
426
|
+
autoAcceptIncoming,
|
|
427
|
+
autoSaveIncoming,
|
|
428
|
+
hideTerminalPeers: true,
|
|
429
|
+
eventsExpanded: showEvents,
|
|
430
|
+
offeringDrafts: false,
|
|
431
|
+
notice: { text: "Tab focus", variant: "info" },
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const toggleButton = (id: string, label: string, active: boolean, onPress: TuiAction, disabled = false) => ui.button({
|
|
436
|
+
id,
|
|
437
|
+
label,
|
|
438
|
+
disabled,
|
|
439
|
+
onPress,
|
|
440
|
+
intent: toggleIntent(active),
|
|
441
|
+
dsVariant: active ? "solid" : "outline",
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
const actionButton = (id: string, label: string, onPress: TuiAction, intent: "secondary" | "warning" | "danger" | "success" | "primary" | "link" = "secondary", disabled = false) => ui.button({
|
|
445
|
+
id,
|
|
446
|
+
label,
|
|
447
|
+
disabled,
|
|
448
|
+
onPress,
|
|
449
|
+
intent,
|
|
450
|
+
dsVariant: intent === "secondary" ? "outline" : "soft",
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
const ghostButton = (id: string, label: string, onPress?: TuiAction, options: { disabled?: boolean; focusable?: boolean } = {}) => ui.button({
|
|
454
|
+
id,
|
|
455
|
+
label,
|
|
456
|
+
...(options.disabled === undefined ? {} : { disabled: options.disabled }),
|
|
457
|
+
...(options.focusable === undefined ? {} : { focusable: options.focusable }),
|
|
458
|
+
...(onPress === undefined ? {} : { onPress }),
|
|
459
|
+
intent: "secondary",
|
|
460
|
+
dsVariant: "ghost",
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
const denseSection = (options: DenseSectionOptions, children: readonly DenseSectionChild[]) => ui.box({
|
|
464
|
+
...(options.id === undefined ? {} : { id: options.id }),
|
|
465
|
+
...(options.key === undefined ? {} : { key: options.key }),
|
|
466
|
+
...(options.flex === undefined ? {} : { flex: options.flex }),
|
|
467
|
+
border: options.border ?? "rounded",
|
|
468
|
+
p: 0,
|
|
469
|
+
}, [
|
|
470
|
+
ui.column({ gap: 0, ...(options.flex === undefined ? {} : { height: "full" as const }) }, [
|
|
471
|
+
options.title !== undefined || options.titleNode !== undefined || (options.actions?.length ?? 0) > 0
|
|
472
|
+
? ui.row({ gap: 0, items: "center", wrap: true }, [
|
|
473
|
+
options.titleNode ?? (options.title !== undefined ? ui.text(options.title, { variant: "heading" }) : null),
|
|
474
|
+
(options.actions?.length ?? 0) > 0 ? ui.spacer({ flex: 1 }) : null,
|
|
475
|
+
...(options.actions ?? []),
|
|
476
|
+
])
|
|
477
|
+
: null,
|
|
478
|
+
options.subtitle !== undefined ? ui.text(options.subtitle, { dim: true }) : null,
|
|
479
|
+
...children,
|
|
480
|
+
]),
|
|
481
|
+
])
|
|
482
|
+
|
|
483
|
+
const renderHeaderBrand = () => ui.row({ id: "brand-title", gap: 1, items: "center" }, [
|
|
484
|
+
ghostButton("brand-icon", "📤", undefined, { focusable: false }),
|
|
485
|
+
ui.text("Send", { id: "brand-label", variant: "heading" }),
|
|
486
|
+
])
|
|
487
|
+
|
|
488
|
+
const renderHeader = (state: TuiState, actions: TuiActions) => denseSection({
|
|
489
|
+
id: "header-shell",
|
|
490
|
+
titleNode: renderHeaderBrand(),
|
|
491
|
+
actions: [toggleButton("toggle-events", "Events", state.eventsExpanded, actions.toggleEvents)],
|
|
492
|
+
}, [])
|
|
493
|
+
|
|
494
|
+
const renderRoomCard = (state: TuiState, actions: TuiActions) => denseSection({
|
|
495
|
+
id: "room-card",
|
|
496
|
+
}, [
|
|
497
|
+
ui.row({ gap: 0, items: "center" }, [
|
|
498
|
+
ghostButton("new-room", "🏠", actions.jumpToRandomRoom),
|
|
499
|
+
ui.box({ flex: 1 }, [
|
|
500
|
+
ui.input({
|
|
501
|
+
id: ROOM_INPUT_ID,
|
|
502
|
+
value: state.roomInput,
|
|
503
|
+
placeholder: "room",
|
|
504
|
+
onInput: value => actions.setRoomInput(value),
|
|
505
|
+
onBlur: actions.commitRoom,
|
|
506
|
+
}),
|
|
507
|
+
]),
|
|
508
|
+
]),
|
|
509
|
+
])
|
|
510
|
+
|
|
511
|
+
const renderSelfMetric = (label: string, value: string) => ui.box({ flex: 1, minWidth: 12, border: "single", borderStyle: METRIC_BORDER_STYLE }, [
|
|
512
|
+
ui.column({ gap: 0 }, [
|
|
513
|
+
ui.text(label, { variant: "caption" }),
|
|
514
|
+
tightTag(value || "—", { variant: statusToneVariant(value), bare: true }),
|
|
515
|
+
]),
|
|
516
|
+
])
|
|
517
|
+
|
|
518
|
+
const renderSelfProfileLine = (value: string) => ui.text(value || "—")
|
|
519
|
+
const formatPeerRtt = (value: number) => Number.isFinite(value) ? `${countFormat.format(Math.round(value))}ms` : "—"
|
|
520
|
+
const renderPeerMetric = (label: string, value: string, asTag = false) => ui.box({ flex: 1, minWidth: 10, border: "single", borderStyle: METRIC_BORDER_STYLE }, [
|
|
521
|
+
ui.column({ gap: 0 }, [
|
|
522
|
+
ui.text(label, { variant: "caption" }),
|
|
523
|
+
asTag ? tightTag(value || "—", { variant: statusToneVariant(value), bare: true }) : ui.text(value || "—"),
|
|
524
|
+
]),
|
|
525
|
+
])
|
|
526
|
+
|
|
527
|
+
const renderSelfCard = (state: TuiState, actions: TuiActions) => denseSection({
|
|
528
|
+
id: "self-card",
|
|
529
|
+
}, [
|
|
530
|
+
ui.row({ gap: 0, items: "center" }, [
|
|
531
|
+
ghostButton("new-self", "🙂", actions.jumpToNewSelf),
|
|
532
|
+
ui.box({ flex: 1 }, [
|
|
533
|
+
ui.input({
|
|
534
|
+
id: NAME_INPUT_ID,
|
|
535
|
+
value: state.nameInput,
|
|
536
|
+
placeholder: fallbackName,
|
|
537
|
+
onInput: value => actions.setNameInput(value),
|
|
538
|
+
onBlur: actions.commitName,
|
|
539
|
+
}),
|
|
540
|
+
]),
|
|
541
|
+
ui.text(`-${state.snapshot.localId}`),
|
|
542
|
+
]),
|
|
543
|
+
ui.row({ gap: 0, wrap: true }, [
|
|
544
|
+
renderSelfMetric("Signaling", state.snapshot.socketState),
|
|
545
|
+
renderSelfMetric("Pulse", state.snapshot.pulse.state),
|
|
546
|
+
renderSelfMetric("TURN", state.snapshot.turnState),
|
|
547
|
+
]),
|
|
548
|
+
ui.column({ gap: 0 }, [
|
|
549
|
+
renderSelfProfileLine(geoSummary(state.snapshot.profile)),
|
|
550
|
+
renderSelfProfileLine(netSummary(state.snapshot.profile)),
|
|
551
|
+
renderSelfProfileLine(uaSummary(state.snapshot.profile)),
|
|
552
|
+
renderSelfProfileLine(profileIp(state.snapshot.profile)),
|
|
553
|
+
]),
|
|
554
|
+
])
|
|
555
|
+
|
|
556
|
+
const renderPeerRow = (peer: PeerSnapshot, actions: TuiActions) => denseSection({
|
|
557
|
+
id: `peer-row-${peer.id}`,
|
|
558
|
+
key: peer.id,
|
|
559
|
+
}, [
|
|
560
|
+
ui.column({ gap: 0 }, [
|
|
561
|
+
ui.row({ id: `peer-head-${peer.id}`, gap: 0, items: "center" }, [
|
|
562
|
+
ui.box({ id: `peer-toggle-slot-${peer.id}`, width: 7, pl: 1, border: "single", borderStyle: TRANSPARENT_BORDER_STYLE }, [
|
|
563
|
+
ui.checkbox({
|
|
564
|
+
id: `peer-toggle-${peer.id}`,
|
|
565
|
+
checked: peer.selectable && peer.selected,
|
|
566
|
+
disabled: !peer.selectable,
|
|
567
|
+
accessibleLabel: `select ${peer.displayName}`,
|
|
568
|
+
focusConfig: { indicator: "none", showHint: false },
|
|
569
|
+
onChange: checked => {
|
|
570
|
+
if (checked !== peer.selected) actions.togglePeer(peer.id)
|
|
571
|
+
},
|
|
572
|
+
}),
|
|
573
|
+
]),
|
|
574
|
+
ui.box({ id: `peer-name-slot-${peer.id}`, flex: 1, minWidth: 0, border: "none" }, [
|
|
575
|
+
ui.text(peer.displayName, {
|
|
576
|
+
id: `peer-name-text-${peer.id}`,
|
|
577
|
+
textOverflow: "ellipsis",
|
|
578
|
+
}),
|
|
579
|
+
]),
|
|
580
|
+
ui.row({ id: `peer-status-cluster-${peer.id}`, gap: 1, items: "center" }, [
|
|
581
|
+
ui.status(peerConnectionStatusKind(peer.status), { label: peer.status || "unknown", showLabel: true }),
|
|
582
|
+
tightTag(peerDefaultsToken(peer.profile), { variant: peerDefaultsVariant(peer.profile), bare: true }),
|
|
583
|
+
]),
|
|
584
|
+
]),
|
|
585
|
+
ui.row({ gap: 0 }, [
|
|
586
|
+
renderPeerMetric("RTT", formatPeerRtt(peer.rttMs)),
|
|
587
|
+
renderPeerMetric("Data", peer.dataState, true),
|
|
588
|
+
]),
|
|
589
|
+
ui.row({ gap: 0 }, [
|
|
590
|
+
renderPeerMetric("TURN", peer.turnState, true),
|
|
591
|
+
renderPeerMetric("Path", peer.pathLabel || "—"),
|
|
592
|
+
]),
|
|
593
|
+
ui.column({ gap: 0 }, [
|
|
594
|
+
renderSelfProfileLine(geoSummary(peer.profile)),
|
|
595
|
+
renderSelfProfileLine(netSummary(peer.profile)),
|
|
596
|
+
renderSelfProfileLine(uaSummary(peer.profile)),
|
|
597
|
+
renderSelfProfileLine(profileIp(peer.profile)),
|
|
598
|
+
]),
|
|
599
|
+
peer.lastError ? ui.callout(peer.lastError, { variant: "error" }) : null,
|
|
600
|
+
]),
|
|
601
|
+
])
|
|
602
|
+
|
|
603
|
+
const renderPeersCard = (state: TuiState, actions: TuiActions) => {
|
|
604
|
+
const peers = visiblePeers(state.snapshot.peers, state.hideTerminalPeers)
|
|
605
|
+
const activeCount = state.snapshot.peers.filter(peer => peer.presence === "active").length
|
|
606
|
+
const selectedCount = state.snapshot.peers.filter(peer => peer.selectable && peer.selected).length
|
|
607
|
+
return denseSection({
|
|
608
|
+
id: "peers-card",
|
|
609
|
+
title: `Peers ${selectedCount}/${activeCount}`,
|
|
610
|
+
flex: 1,
|
|
611
|
+
actions: [
|
|
612
|
+
actionButton("select-ready-peers", "All", actions.toggleSelectReadyPeers),
|
|
613
|
+
actionButton("clear-peer-selection", "None", actions.clearPeerSelection),
|
|
614
|
+
toggleButton("toggle-clean-peers", "Clean", state.hideTerminalPeers, actions.toggleHideTerminalPeers),
|
|
615
|
+
],
|
|
616
|
+
}, [
|
|
617
|
+
ui.box({ id: "peers-list", flex: 1, minHeight: 0, overflow: "scroll", border: "none" }, [
|
|
618
|
+
peers.length
|
|
619
|
+
? ui.column({ gap: 0 }, peers.map(peer => renderPeerRow(peer, actions)))
|
|
620
|
+
: ui.empty(`Waiting for peers in ${state.snapshot.room}...`),
|
|
621
|
+
]),
|
|
622
|
+
])
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const renderDraftRow = (draft: DraftItem, actions: TuiActions) => ui.row({ key: draft.id, gap: 0, items: "center" }, [
|
|
626
|
+
ui.box({ flex: 1 }, [
|
|
627
|
+
ui.column({ gap: 0 }, [
|
|
628
|
+
ui.text(draft.name),
|
|
629
|
+
ui.text(formatBytes(draft.size), { style: { dim: true } }),
|
|
630
|
+
]),
|
|
631
|
+
]),
|
|
632
|
+
actionButton(`remove-draft-${draft.id}`, "✕", () => actions.removeDraft(draft.id), "warning"),
|
|
633
|
+
])
|
|
634
|
+
|
|
635
|
+
const renderDraftSummary = (drafts: DraftItem[]) => denseSection({
|
|
636
|
+
id: "drafts-summary",
|
|
637
|
+
title: "Total",
|
|
638
|
+
border: "single",
|
|
639
|
+
}, [
|
|
640
|
+
ui.row({ gap: 1, wrap: true }, summarizeStates(drafts, () => "draft", draft => draft.size, ["draft"]).map(renderSummaryStat)),
|
|
641
|
+
])
|
|
642
|
+
|
|
643
|
+
const renderHighlightedPreviewPath = (value: string, indices: number[], options: { id?: string; key?: string; flex?: number } = {}) => ui.row({
|
|
644
|
+
gap: 0,
|
|
645
|
+
wrap: true,
|
|
646
|
+
...(options.id === undefined ? {} : { id: options.id }),
|
|
647
|
+
...(options.key === undefined ? {} : { key: options.key }),
|
|
648
|
+
...(options.flex === undefined ? {} : { flex: options.flex }),
|
|
649
|
+
}, highlightedSegments(value, indices).map((segment, index) =>
|
|
650
|
+
ui.text(segment.text, { key: `segment-${index}`, ...(segment.highlighted ? { style: { bold: true } } : {}) }),
|
|
651
|
+
))
|
|
652
|
+
|
|
653
|
+
const renderFilePreviewRow = (match: FileSearchMatch, index: number, selected: boolean, displayPrefix: string) => ui.row({
|
|
654
|
+
id: `file-preview-row-${index}`,
|
|
655
|
+
key: `${match.kind}:${match.relativePath}`,
|
|
656
|
+
gap: 1,
|
|
657
|
+
wrap: true,
|
|
658
|
+
}, [
|
|
659
|
+
ui.text(selected ? ">" : " "),
|
|
660
|
+
renderHighlightedPreviewPath(
|
|
661
|
+
formatFileSearchDisplayPath(displayPrefix, match.relativePath),
|
|
662
|
+
offsetFileSearchMatchIndices(displayPrefix, match.indices),
|
|
663
|
+
{ id: `file-preview-path-${index}`, flex: 1 },
|
|
664
|
+
),
|
|
665
|
+
match.kind === "directory" ? tightTag("dir", { variant: "info", bare: true }) : null,
|
|
666
|
+
])
|
|
667
|
+
|
|
668
|
+
const renderFilePreview = (state: TuiState) => {
|
|
669
|
+
if (!filePreviewVisible(state)) return null
|
|
670
|
+
const preview = state.filePreview
|
|
671
|
+
const rows = visibleFilePreviewResults(preview)
|
|
672
|
+
const matchCountText = `${countFormat.format(preview.results.length)} ${preview.results.length === 1 ? "match" : "matches"}`
|
|
673
|
+
const statusText = preview.waiting
|
|
674
|
+
? "searching..."
|
|
675
|
+
: preview.results.length
|
|
676
|
+
? matchCountText
|
|
677
|
+
: "no matches"
|
|
678
|
+
return denseSection({
|
|
679
|
+
id: "draft-preview",
|
|
680
|
+
border: "single",
|
|
681
|
+
}, [
|
|
682
|
+
ui.text(statusText, { id: "draft-preview-status", style: { dim: true } }),
|
|
683
|
+
ui.text(preview.error || " ", { id: "draft-preview-error", style: { dim: true } }),
|
|
684
|
+
rows.length ? ui.column({ gap: 0 }, rows.map((match, offset) => renderFilePreviewRow(match, preview.scrollTop + offset, preview.selectedIndex === preview.scrollTop + offset, preview.displayPrefix))) : null,
|
|
685
|
+
])
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const renderSummaryStat = (stat: TransferSummaryStat) => {
|
|
689
|
+
const countText = stat.countText ?? countFormat.format(stat.count)
|
|
690
|
+
const sizeText = stat.sizeText ?? (stat.size ? formatBytes(stat.size) : "")
|
|
691
|
+
const text = `${stat.label || stat.state} ${countText}${sizeText ? ` ${sizeText}` : ""}`
|
|
692
|
+
const variant = stat.state === "duration" ? "default" : statusVariant(stat.state as TransferSnapshot["status"])
|
|
693
|
+
return tightTag(text, { variant, bare: true })
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const transferActionButtons = (transfer: TransferSnapshot, actions: TuiActions): VNode[] => {
|
|
697
|
+
if (transfer.status === "pending") {
|
|
698
|
+
return [
|
|
699
|
+
actionButton(`reject-${transfer.id}`, "Reject", () => actions.rejectTransfer(transfer.id), "warning"),
|
|
700
|
+
actionButton(`accept-${transfer.id}`, "Accept", () => actions.acceptTransfer(transfer.id), "success"),
|
|
701
|
+
]
|
|
702
|
+
}
|
|
703
|
+
if (!["complete", "rejected", "cancelled", "error"].includes(transfer.status)) {
|
|
704
|
+
return [actionButton(`cancel-${transfer.id}`, "Cancel", () => actions.cancelTransfer(transfer.id), "warning")]
|
|
705
|
+
}
|
|
706
|
+
if (transfer.direction === "in" && transfer.status === "complete" && !transfer.savedAt) {
|
|
707
|
+
return [actionButton(`save-${transfer.id}`, "Save", () => actions.saveTransfer(transfer.id), "success")]
|
|
708
|
+
}
|
|
709
|
+
return []
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const renderTransferFact = (label: string, value: string) => ui.box({ minWidth: 12 }, [
|
|
713
|
+
ui.column({ gap: 0 }, [
|
|
714
|
+
ui.text(label, { variant: "caption" }),
|
|
715
|
+
ui.text(value),
|
|
716
|
+
]),
|
|
717
|
+
])
|
|
718
|
+
|
|
719
|
+
const transferPathLabel = (transfer: TransferSnapshot, peersById: Map<string, PeerSnapshot>) => peersById.get(transfer.peerId)?.pathLabel || "—"
|
|
720
|
+
|
|
721
|
+
const renderTransferRow = (transfer: TransferSnapshot, peersById: Map<string, PeerSnapshot>, actions: TuiActions, now = Date.now()) => {
|
|
722
|
+
const hasStarted = !!transfer.startedAt
|
|
723
|
+
const facts = [
|
|
724
|
+
renderTransferFact("Size", formatBytes(transfer.size)),
|
|
725
|
+
renderTransferFact("Path", transferPathLabel(transfer, peersById)),
|
|
726
|
+
renderTransferFact("Created", formatClockTime(transfer.createdAt)),
|
|
727
|
+
!hasStarted ? renderTransferFact("Waiting", formatDuration(transferWaitDurationMs(transfer, now))) : null,
|
|
728
|
+
hasStarted ? renderTransferFact("Start", formatClockTime(transfer.startedAt)) : null,
|
|
729
|
+
hasStarted ? renderTransferFact("End", formatClockTime(transfer.endedAt)) : null,
|
|
730
|
+
hasStarted ? renderTransferFact("Duration", formatDuration(transferActualDurationMs(transfer, now))) : null,
|
|
731
|
+
].filter(Boolean) as VNode[]
|
|
732
|
+
|
|
733
|
+
return denseSection({
|
|
734
|
+
key: transfer.id,
|
|
735
|
+
title: `${transfer.direction === "out" ? "→" : "←"} ${transfer.name}`,
|
|
736
|
+
actions: transferActionButtons(transfer, actions),
|
|
737
|
+
}, [
|
|
738
|
+
ui.row({ gap: 1, wrap: true }, [
|
|
739
|
+
tightTag(transfer.status, { variant: statusVariant(transfer.status), bare: true }),
|
|
740
|
+
transfer.error ? tightTag("error", { variant: "error", bare: true }) : null,
|
|
741
|
+
]),
|
|
742
|
+
ui.row({ gap: 0, wrap: true }, facts),
|
|
743
|
+
ui.progress(transferProgress(transfer), { showPercent: true, label: `${percentFormat.format(transfer.progress)}%` }),
|
|
744
|
+
ui.row({ gap: 0, wrap: true }, [
|
|
745
|
+
renderTransferFact("Speed", hasStarted ? transfer.speedText : "—"),
|
|
746
|
+
renderTransferFact("ETA", transfer.status === "sending" || transfer.status === "receiving" ? transfer.etaText : "—"),
|
|
747
|
+
]),
|
|
748
|
+
transfer.error ? ui.callout(transfer.error, { variant: "error" }) : null,
|
|
749
|
+
])
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const renderTransferGroup = (group: TransferGroup, peersById: Map<string, PeerSnapshot>, actions: TuiActions, now = Date.now()) => denseSection({
|
|
753
|
+
key: `group-${group.key}`,
|
|
754
|
+
title: group.name,
|
|
755
|
+
}, [
|
|
756
|
+
ui.row({ gap: 1, wrap: true }, transferSummaryStats(group.items, now).map(renderSummaryStat)),
|
|
757
|
+
ui.column({ gap: 0 }, group.items.map(transfer => renderTransferRow(transfer, peersById, actions, now))),
|
|
758
|
+
])
|
|
759
|
+
|
|
760
|
+
const renderTransferSection = (section: TransferSection, peersById: Map<string, PeerSnapshot>, actions: TuiActions, now = Date.now()) => {
|
|
761
|
+
if (!section.items.length) return null
|
|
762
|
+
const groups = groupTransfersByPeer(section.items, [...peersById.values()])
|
|
763
|
+
const pendingOfferCount = section.title === "Pending" ? section.items.filter(isPendingOffer).length : 0
|
|
764
|
+
const children: VNode[] = []
|
|
765
|
+
if (groups.length > 1) {
|
|
766
|
+
children.push(denseSection({ key: `${section.title}-total`, title: "Total" }, [
|
|
767
|
+
ui.row({ gap: 1, wrap: true }, transferSummaryStats(section.items, now).map(renderSummaryStat)),
|
|
768
|
+
]))
|
|
769
|
+
}
|
|
770
|
+
children.push(...groups.map(group => renderTransferGroup(group, peersById, actions, now)))
|
|
771
|
+
return denseSection({
|
|
772
|
+
id: `${section.title.toLowerCase()}-card`,
|
|
773
|
+
title: section.title,
|
|
774
|
+
actions: section.title === "Pending"
|
|
775
|
+
? [actionButton("cancel-pending", "Cancel", actions.cancelPendingOffers, "warning", pendingOfferCount === 0)]
|
|
776
|
+
: section.clearAction === "completed"
|
|
777
|
+
? [actionButton("clear-completed", "Clear", actions.clearCompleted, "warning")]
|
|
778
|
+
: section.clearAction === "failed"
|
|
779
|
+
? [actionButton("clear-failed", "Clear", actions.clearFailed, "warning")]
|
|
780
|
+
: [],
|
|
781
|
+
}, children)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const renderFilesCard = (state: TuiState, actions: TuiActions) => denseSection({
|
|
785
|
+
id: "files-card",
|
|
786
|
+
title: "Files",
|
|
787
|
+
actions: [
|
|
788
|
+
ui.row({ id: "files-actions", gap: 1, items: "center", wrap: true }, [
|
|
789
|
+
toggleButton("toggle-offer", "Offer", state.autoOfferOutgoing, actions.toggleAutoOffer),
|
|
790
|
+
ui.row({ id: "files-mode-actions", gap: 0, items: "center" }, [
|
|
791
|
+
toggleButton("toggle-accept", "Accept", state.autoAcceptIncoming, actions.toggleAutoAccept),
|
|
792
|
+
toggleButton("toggle-save", "Save", state.autoSaveIncoming, actions.toggleAutoSave),
|
|
793
|
+
]),
|
|
794
|
+
actionButton("clear-drafts", "Clear", actions.clearDrafts, "warning"),
|
|
795
|
+
]),
|
|
796
|
+
],
|
|
797
|
+
}, [
|
|
798
|
+
ui.row({ id: "files-input-row", gap: 0, items: "center" }, [
|
|
799
|
+
ui.box({ flex: 1 }, [
|
|
800
|
+
ui.input({
|
|
801
|
+
id: DRAFT_INPUT_ID,
|
|
802
|
+
key: `draft-input-${state.draftInputKeyVersion}`,
|
|
803
|
+
value: state.draftInput,
|
|
804
|
+
placeholder: "path/to/file.txt",
|
|
805
|
+
onInput: value => actions.setDraftInput(value),
|
|
806
|
+
}),
|
|
807
|
+
]),
|
|
808
|
+
actionButton("add-drafts", "Add", actions.addDrafts, "primary", !state.draftInput.trim()),
|
|
809
|
+
]),
|
|
810
|
+
renderFilePreview(state),
|
|
811
|
+
state.offeringDrafts ? ui.row({ gap: 0, items: "center" }, [ui.spinner({ label: "Offering drafts..." })]) : null,
|
|
812
|
+
state.drafts.length > 1 ? renderDraftSummary(state.drafts) : null,
|
|
813
|
+
state.drafts.length
|
|
814
|
+
? ui.box({ id: "drafts-view", maxHeight: 10, overflow: "scroll" }, [
|
|
815
|
+
ui.column({ gap: 0 }, state.drafts.map(draft => renderDraftRow(draft, actions))),
|
|
816
|
+
])
|
|
817
|
+
: null,
|
|
818
|
+
])
|
|
819
|
+
|
|
820
|
+
const renderLogRow = (log: LogEntry) => denseSection({
|
|
821
|
+
key: log.id,
|
|
822
|
+
title: log.kind,
|
|
823
|
+
subtitle: timeFormat.format(log.at),
|
|
824
|
+
}, [
|
|
825
|
+
ui.text(shortText(log.payload), { style: { dim: true } }),
|
|
826
|
+
])
|
|
827
|
+
|
|
828
|
+
const renderEventsCard = (state: TuiState, actions: TuiActions) => denseSection({
|
|
829
|
+
id: "events-card",
|
|
830
|
+
title: "Events",
|
|
831
|
+
actions: [
|
|
832
|
+
actionButton("clear-events", "Clear", actions.clearLogs, "warning", !state.snapshot.logs.length),
|
|
833
|
+
actionButton("hide-events", "Hide", actions.toggleEvents),
|
|
834
|
+
],
|
|
835
|
+
}, [
|
|
836
|
+
ui.box({ maxHeight: 24, overflow: "scroll" }, [
|
|
837
|
+
state.snapshot.logs.length
|
|
838
|
+
? ui.column({ gap: 0 }, state.snapshot.logs.slice(0, 20).map(renderLogRow))
|
|
839
|
+
: ui.empty("No events"),
|
|
840
|
+
]),
|
|
841
|
+
])
|
|
842
|
+
|
|
843
|
+
const renderFooterHint = (id: string, keycap: string, label: string) => ui.row({ id, gap: 0, items: "center" }, [
|
|
844
|
+
ui.kbd(keycap),
|
|
845
|
+
ui.text(` ${label}`, { style: { dim: true } }),
|
|
846
|
+
])
|
|
847
|
+
|
|
848
|
+
const renderFooter = (state: TuiState) => ui.statusBar({
|
|
849
|
+
id: "footer-shell",
|
|
850
|
+
left: [ui.callout(state.notice.text, { variant: state.notice.variant })],
|
|
851
|
+
right: [
|
|
852
|
+
ui.toolbar({ id: "footer-hints", gap: 3 }, [
|
|
853
|
+
renderFooterHint("footer-hint-tab", "tab", "focus/accept"),
|
|
854
|
+
renderFooterHint("footer-hint-enter", "enter", "accept/add"),
|
|
855
|
+
renderFooterHint("footer-hint-esc", "esc", "hide/reset"),
|
|
856
|
+
renderFooterHint("footer-hint-ctrlc", "ctrl+c", "quit"),
|
|
857
|
+
]),
|
|
858
|
+
],
|
|
859
|
+
})
|
|
860
|
+
|
|
861
|
+
export const renderTuiView = (state: TuiState, actions: TuiActions): VNode => {
|
|
862
|
+
const peers = visiblePeers(state.snapshot.peers, state.hideTerminalPeers)
|
|
863
|
+
const peersById = new Map(peers.map(peer => [peer.id, peer] as const))
|
|
864
|
+
const now = Date.now()
|
|
865
|
+
const transferCards = transferSections(state.snapshot)
|
|
866
|
+
.map(section => renderTransferSection(section, peersById, actions, now))
|
|
867
|
+
.filter((section): section is VNode => !!section)
|
|
868
|
+
|
|
869
|
+
const page = ui.page({
|
|
870
|
+
header: renderHeader(state, actions),
|
|
871
|
+
body: ui.row({ id: "body-shell", gap: 1, items: "stretch", flex: 1, minHeight: 0 }, [
|
|
872
|
+
ui.column({ id: "sidebar", width: 45, minWidth: 36, maxWidth: 51, gap: 0, minHeight: 0 }, [
|
|
873
|
+
renderRoomCard(state, actions),
|
|
874
|
+
renderSelfCard(state, actions),
|
|
875
|
+
renderPeersCard(state, actions),
|
|
876
|
+
]),
|
|
877
|
+
ui.box({ id: "main-scroll", flex: 1, minHeight: 0, overflow: "scroll", border: "none" }, [
|
|
878
|
+
ui.column({ gap: 0 }, [
|
|
879
|
+
renderFilesCard(state, actions),
|
|
880
|
+
...transferCards,
|
|
881
|
+
]),
|
|
882
|
+
]),
|
|
883
|
+
state.eventsExpanded ? ui.box({ id: "events-shell", width: 28, minHeight: 0 }, [renderEventsCard(state, actions)]) : null,
|
|
884
|
+
]),
|
|
885
|
+
footer: renderFooter(state),
|
|
886
|
+
p: 0,
|
|
887
|
+
gap: 0,
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
return state.pendingFocusTarget
|
|
891
|
+
? ui.focusTrap({
|
|
892
|
+
id: `focus-request-${state.focusRequestEpoch}`,
|
|
893
|
+
key: `focus-request-${state.focusRequestEpoch}`,
|
|
894
|
+
active: true,
|
|
895
|
+
initialFocus: state.pendingFocusTarget,
|
|
896
|
+
}, [page])
|
|
897
|
+
: page
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const withNotice = (state: TuiState, notice: Notice): TuiState => ({ ...state, notice })
|
|
901
|
+
|
|
902
|
+
export const withAcceptedDraftInput = (state: TuiState, draftInput: string, filePreview: FilePreviewState, notice: Notice): TuiState =>
|
|
903
|
+
withNotice({
|
|
904
|
+
...state,
|
|
905
|
+
draftInput,
|
|
906
|
+
draftInputKeyVersion: state.draftInputKeyVersion + 1,
|
|
907
|
+
filePreview,
|
|
908
|
+
pendingFocusTarget: DRAFT_INPUT_ID,
|
|
909
|
+
focusRequestEpoch: state.focusRequestEpoch + 1,
|
|
910
|
+
}, notice)
|
|
911
|
+
|
|
912
|
+
export const startTui = async (initialConfig: SessionConfig, showEvents = false) => {
|
|
913
|
+
installCheckboxClickPatch()
|
|
914
|
+
const initialState = createInitialTuiState(initialConfig, showEvents)
|
|
915
|
+
const app = createNodeApp<TuiState>({ initialState })
|
|
916
|
+
let state = initialState
|
|
917
|
+
let unsubscribe = () => {}
|
|
918
|
+
let stopping = false
|
|
919
|
+
let cleanedUp = false
|
|
920
|
+
let updateQueued = false
|
|
921
|
+
const previewBaseRoot = process.cwd()
|
|
922
|
+
let previewWorker: Worker | null = null
|
|
923
|
+
let previewSessionId: string | null = null
|
|
924
|
+
let previewSessionRoot: string | null = null
|
|
925
|
+
|
|
926
|
+
const flushUpdate = () => {
|
|
927
|
+
if (updateQueued || stopping || cleanedUp) return
|
|
928
|
+
updateQueued = true
|
|
929
|
+
queueMicrotask(() => {
|
|
930
|
+
updateQueued = false
|
|
931
|
+
if (stopping || cleanedUp) return
|
|
932
|
+
try {
|
|
933
|
+
app.update(state)
|
|
934
|
+
} catch (error) {
|
|
935
|
+
const message = error instanceof Error ? error.message : `${error}`
|
|
936
|
+
if (message.includes("lifecycle operation already in flight")) {
|
|
937
|
+
setTimeout(flushUpdate, 0)
|
|
938
|
+
return
|
|
939
|
+
}
|
|
940
|
+
if (message.includes("app is Disposed")) return
|
|
941
|
+
throw error
|
|
942
|
+
}
|
|
943
|
+
})
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const commit = (updater: TuiState | ((prev: Readonly<TuiState>) => TuiState)) => {
|
|
947
|
+
state = typeof updater === "function" ? updater(state) : updater
|
|
948
|
+
flushUpdate()
|
|
949
|
+
return state
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const requestStop = () => {
|
|
953
|
+
if (stopping) return
|
|
954
|
+
stopping = true
|
|
955
|
+
void app.stop()
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const resetFilePreview = (overrides: Partial<FilePreviewState> = {}): FilePreviewState => ({
|
|
959
|
+
...emptyFilePreviewState(),
|
|
960
|
+
...overrides,
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
const ensurePreviewSession = (workspaceRoot: string) => {
|
|
964
|
+
if (previewWorker && previewSessionId && previewSessionRoot === workspaceRoot) return
|
|
965
|
+
if (previewWorker || previewSessionId) stopPreviewSession()
|
|
966
|
+
previewSessionId = uid(8)
|
|
967
|
+
previewSessionRoot = workspaceRoot
|
|
968
|
+
previewWorker = new Worker(new URL("./file-search.worker.ts", import.meta.url).href, { type: "module" })
|
|
969
|
+
previewWorker.onmessage = ({ data }: MessageEvent<FileSearchEvent>) => {
|
|
970
|
+
if (!previewSessionId || data.sessionId !== previewSessionId) return
|
|
971
|
+
if (data.type === "update") {
|
|
972
|
+
commit(current => {
|
|
973
|
+
if (current.filePreview.pendingQuery !== data.query || current.filePreview.workspaceRoot !== previewSessionRoot) return current
|
|
974
|
+
const selectedIndex = clampFilePreviewSelectedIndex(current.filePreview.selectedIndex, data.matches.length)
|
|
975
|
+
return {
|
|
976
|
+
...current,
|
|
977
|
+
filePreview: {
|
|
978
|
+
...current.filePreview,
|
|
979
|
+
displayQuery: data.query,
|
|
980
|
+
pendingQuery: data.query,
|
|
981
|
+
waiting: !data.walkComplete,
|
|
982
|
+
error: null,
|
|
983
|
+
results: data.matches,
|
|
984
|
+
selectedIndex,
|
|
985
|
+
scrollTop: ensureFilePreviewScrollTop(selectedIndex, current.filePreview.scrollTop, data.matches.length),
|
|
986
|
+
},
|
|
987
|
+
}
|
|
988
|
+
})
|
|
989
|
+
return
|
|
990
|
+
}
|
|
991
|
+
if (data.type === "complete") {
|
|
992
|
+
commit(current => {
|
|
993
|
+
if (current.filePreview.pendingQuery !== data.query || current.filePreview.workspaceRoot !== previewSessionRoot) return current
|
|
994
|
+
return {
|
|
995
|
+
...current,
|
|
996
|
+
filePreview: {
|
|
997
|
+
...current.filePreview,
|
|
998
|
+
displayQuery: data.query,
|
|
999
|
+
waiting: false,
|
|
1000
|
+
error: null,
|
|
1001
|
+
},
|
|
1002
|
+
}
|
|
1003
|
+
})
|
|
1004
|
+
return
|
|
1005
|
+
}
|
|
1006
|
+
commit(current => {
|
|
1007
|
+
if (current.filePreview.pendingQuery !== data.query || current.filePreview.workspaceRoot !== previewSessionRoot) return current
|
|
1008
|
+
return {
|
|
1009
|
+
...current,
|
|
1010
|
+
filePreview: {
|
|
1011
|
+
...current.filePreview,
|
|
1012
|
+
waiting: false,
|
|
1013
|
+
error: data.message,
|
|
1014
|
+
displayQuery: data.query,
|
|
1015
|
+
},
|
|
1016
|
+
}
|
|
1017
|
+
})
|
|
1018
|
+
}
|
|
1019
|
+
previewWorker.onerror = event => {
|
|
1020
|
+
commit(current => ({
|
|
1021
|
+
...current,
|
|
1022
|
+
filePreview: {
|
|
1023
|
+
...current.filePreview,
|
|
1024
|
+
waiting: false,
|
|
1025
|
+
error: event.message || "File preview worker failed.",
|
|
1026
|
+
},
|
|
1027
|
+
}))
|
|
1028
|
+
}
|
|
1029
|
+
previewWorker.postMessage({
|
|
1030
|
+
type: "create-session",
|
|
1031
|
+
sessionId: previewSessionId,
|
|
1032
|
+
workspaceRoot,
|
|
1033
|
+
} satisfies FileSearchRequest)
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const stopPreviewSession = () => {
|
|
1037
|
+
if (!previewWorker || !previewSessionId) return
|
|
1038
|
+
previewWorker.postMessage({ type: "dispose-session", sessionId: previewSessionId } satisfies FileSearchRequest)
|
|
1039
|
+
previewWorker.terminate()
|
|
1040
|
+
previewWorker = null
|
|
1041
|
+
previewSessionId = null
|
|
1042
|
+
previewSessionRoot = null
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const requestFilePreview = (value: string) => {
|
|
1046
|
+
const scope = deriveFileSearchScope(value, previewBaseRoot)
|
|
1047
|
+
if (!scope) {
|
|
1048
|
+
stopPreviewSession()
|
|
1049
|
+
return null
|
|
1050
|
+
}
|
|
1051
|
+
ensurePreviewSession(scope.workspaceRoot)
|
|
1052
|
+
if (!previewWorker || !previewSessionId) return
|
|
1053
|
+
previewWorker.postMessage({ type: "update-query", sessionId: previewSessionId, query: scope.query } satisfies FileSearchRequest)
|
|
1054
|
+
return scope
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const updateDraftInput = (value: string) => {
|
|
1058
|
+
const scope = deriveFileSearchScope(value, previewBaseRoot)
|
|
1059
|
+
const shouldDispose = !scope || state.filePreview.dismissedQuery === value
|
|
1060
|
+
commit(current => {
|
|
1061
|
+
if (!scope) return { ...current, draftInput: value, filePreview: resetFilePreview() }
|
|
1062
|
+
const shouldDismiss = current.filePreview.dismissedQuery === value
|
|
1063
|
+
const rootChanged = current.filePreview.workspaceRoot !== scope.workspaceRoot
|
|
1064
|
+
const basePreview = rootChanged ? resetFilePreview() : current.filePreview
|
|
1065
|
+
return {
|
|
1066
|
+
...current,
|
|
1067
|
+
draftInput: value,
|
|
1068
|
+
filePreview: {
|
|
1069
|
+
...basePreview,
|
|
1070
|
+
workspaceRoot: scope.workspaceRoot,
|
|
1071
|
+
displayPrefix: scope.displayPrefix,
|
|
1072
|
+
dismissedQuery: shouldDismiss ? current.filePreview.dismissedQuery : null,
|
|
1073
|
+
pendingQuery: shouldDismiss ? current.filePreview.pendingQuery : scope.query,
|
|
1074
|
+
waiting: shouldDismiss ? false : true,
|
|
1075
|
+
error: null,
|
|
1076
|
+
},
|
|
1077
|
+
}
|
|
1078
|
+
})
|
|
1079
|
+
if (shouldDispose) {
|
|
1080
|
+
stopPreviewSession()
|
|
1081
|
+
return
|
|
1082
|
+
}
|
|
1083
|
+
requestFilePreview(value)
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const acceptSelectedFilePreview = () => {
|
|
1087
|
+
const match = selectedFilePreviewMatch(state)
|
|
1088
|
+
if (!match || !filePreviewVisible(state)) return false
|
|
1089
|
+
const displayPath = formatFileSearchDisplayPath(state.filePreview.displayPrefix, match.relativePath)
|
|
1090
|
+
if (match.kind === "directory") {
|
|
1091
|
+
const nextValue = `${displayPath}/`
|
|
1092
|
+
const nextScope = deriveFileSearchScope(nextValue, previewBaseRoot)
|
|
1093
|
+
if (!nextScope) return false
|
|
1094
|
+
commit(current => withAcceptedDraftInput(current, nextValue, {
|
|
1095
|
+
...resetFilePreview(),
|
|
1096
|
+
workspaceRoot: nextScope.workspaceRoot,
|
|
1097
|
+
displayPrefix: nextScope.displayPrefix,
|
|
1098
|
+
dismissedQuery: null,
|
|
1099
|
+
pendingQuery: nextScope.query,
|
|
1100
|
+
waiting: true,
|
|
1101
|
+
error: null,
|
|
1102
|
+
selectedIndex: null,
|
|
1103
|
+
scrollTop: 0,
|
|
1104
|
+
}, { text: `Browsing ${nextValue}`, variant: "info" }))
|
|
1105
|
+
requestFilePreview(nextValue)
|
|
1106
|
+
return true
|
|
1107
|
+
}
|
|
1108
|
+
commit(current => withAcceptedDraftInput(
|
|
1109
|
+
current,
|
|
1110
|
+
displayPath,
|
|
1111
|
+
resetFilePreview({ dismissedQuery: displayPath }),
|
|
1112
|
+
{ text: `Selected ${displayPath}.`, variant: "success" },
|
|
1113
|
+
))
|
|
1114
|
+
stopPreviewSession()
|
|
1115
|
+
return true
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const maybeOfferDrafts = () => {
|
|
1119
|
+
if (!state.autoOfferOutgoing || !state.drafts.length || state.offeringDrafts) return
|
|
1120
|
+
if (!state.snapshot.peers.some(peer => peer.presence === "active" && peer.ready && peer.selected)) return
|
|
1121
|
+
const session = state.session
|
|
1122
|
+
const pendingDrafts = [...state.drafts]
|
|
1123
|
+
commit(current => ({ ...current, offeringDrafts: true }))
|
|
1124
|
+
void session.offerToSelectedPeers(pendingDrafts.map(draft => draft.path)).then(
|
|
1125
|
+
ids => {
|
|
1126
|
+
if (state.session !== session) return
|
|
1127
|
+
const offeredIds = new Set(pendingDrafts.map(draft => draft.id))
|
|
1128
|
+
commit(current => withNotice({
|
|
1129
|
+
...current,
|
|
1130
|
+
drafts: current.drafts.filter(draft => !offeredIds.has(draft.id)),
|
|
1131
|
+
offeringDrafts: false,
|
|
1132
|
+
}, { text: `Queued ${plural(ids.length, "transfer")}.`, variant: "success" }))
|
|
1133
|
+
},
|
|
1134
|
+
error => {
|
|
1135
|
+
if (state.session !== session) return
|
|
1136
|
+
commit(current => withNotice({ ...current, offeringDrafts: false }, { text: `${error}`, variant: "error" }))
|
|
1137
|
+
},
|
|
1138
|
+
)
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const bindSession = (session: SendSession) => {
|
|
1142
|
+
unsubscribe()
|
|
1143
|
+
unsubscribe = session.subscribe(() => {
|
|
1144
|
+
commit(current => current.session === session ? { ...current, snapshot: session.snapshot() } : current)
|
|
1145
|
+
maybeOfferDrafts()
|
|
1146
|
+
})
|
|
1147
|
+
commit(current => current.session === session ? { ...current, snapshot: session.snapshot() } : current)
|
|
1148
|
+
void session.connect().catch(error => {
|
|
1149
|
+
if (state.session !== session) return
|
|
1150
|
+
commit(current => withNotice(current, { text: `${error}`, variant: "error" }))
|
|
1151
|
+
})
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const replaceSession = (nextSeed: SessionSeed, text: string, options: { reseedBootFocus?: boolean } = {}) => {
|
|
1155
|
+
const previousSession = state.session
|
|
1156
|
+
const nextSession = makeSession(nextSeed, state.autoAcceptIncoming, state.autoSaveIncoming)
|
|
1157
|
+
stopPreviewSession()
|
|
1158
|
+
commit(current => withNotice({
|
|
1159
|
+
...current,
|
|
1160
|
+
session: nextSession,
|
|
1161
|
+
sessionSeed: nextSeed,
|
|
1162
|
+
snapshot: nextSession.snapshot(),
|
|
1163
|
+
roomInput: nextSeed.room,
|
|
1164
|
+
nameInput: visibleNameInput(nextSeed.name),
|
|
1165
|
+
draftInput: "",
|
|
1166
|
+
filePreview: resetFilePreview(),
|
|
1167
|
+
drafts: [],
|
|
1168
|
+
offeringDrafts: false,
|
|
1169
|
+
...(options.reseedBootFocus
|
|
1170
|
+
? deriveBootFocusState(nextSeed.name, current.focusRequestEpoch + 1)
|
|
1171
|
+
: {
|
|
1172
|
+
pendingFocusTarget: current.pendingFocusTarget,
|
|
1173
|
+
focusRequestEpoch: current.focusRequestEpoch,
|
|
1174
|
+
bootNameJumpPending: current.bootNameJumpPending,
|
|
1175
|
+
}),
|
|
1176
|
+
}, { text, variant: "success" }))
|
|
1177
|
+
bindSession(nextSession)
|
|
1178
|
+
void previousSession.close()
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const commitRoom = () => {
|
|
1182
|
+
const nextRoom = cleanRoom(state.roomInput)
|
|
1183
|
+
if (nextRoom === state.sessionSeed.room) {
|
|
1184
|
+
commit(current => current.roomInput === nextRoom ? current : withNotice({ ...current, roomInput: nextRoom }, { text: `Room ${nextRoom}.`, variant: "info" }))
|
|
1185
|
+
return
|
|
1186
|
+
}
|
|
1187
|
+
replaceSession({ ...state.sessionSeed, room: nextRoom }, `Joined room ${nextRoom}.`)
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const commitName = () => {
|
|
1191
|
+
const nextName = state.session.setName(state.nameInput)
|
|
1192
|
+
commit(current => scheduleBootNameJump(withNotice({
|
|
1193
|
+
...current,
|
|
1194
|
+
nameInput: visibleNameInput(nextName),
|
|
1195
|
+
sessionSeed: { ...current.sessionSeed, name: nextName },
|
|
1196
|
+
snapshot: current.session.snapshot(),
|
|
1197
|
+
}, { text: `Self name is ${nextName}.`, variant: "success" })))
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const addDrafts = () => {
|
|
1201
|
+
const submittedInput = state.draftInput
|
|
1202
|
+
if (!normalizeSearchQuery(submittedInput)) {
|
|
1203
|
+
commit(current => withNotice(current, { text: "No file paths entered.", variant: "warning" }))
|
|
1204
|
+
return
|
|
1205
|
+
}
|
|
1206
|
+
void inspectLocalFile(submittedInput).then(file => {
|
|
1207
|
+
const shouldDispose = state.draftInput === submittedInput
|
|
1208
|
+
const created = {
|
|
1209
|
+
id: uid(10),
|
|
1210
|
+
path: file.path,
|
|
1211
|
+
name: file.name,
|
|
1212
|
+
size: file.size,
|
|
1213
|
+
createdAt: Date.now(),
|
|
1214
|
+
}
|
|
1215
|
+
commit(current => withNotice({
|
|
1216
|
+
...current,
|
|
1217
|
+
draftInput: current.draftInput === submittedInput ? "" : current.draftInput,
|
|
1218
|
+
filePreview: current.draftInput === submittedInput ? resetFilePreview() : current.filePreview,
|
|
1219
|
+
drafts: [...current.drafts, created],
|
|
1220
|
+
}, { text: `Added ${plural(1, "draft file")}.`, variant: "success" }))
|
|
1221
|
+
if (shouldDispose) stopPreviewSession()
|
|
1222
|
+
maybeOfferDrafts()
|
|
1223
|
+
}, error => {
|
|
1224
|
+
commit(current => withNotice(current, { text: `${error}`, variant: "error" }))
|
|
1225
|
+
})
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const actions: TuiActions = {
|
|
1229
|
+
toggleEvents: () => commit(current => ({ ...withNotice(current, { text: current.eventsExpanded ? "Events hidden." : "Events shown.", variant: "info" }), eventsExpanded: !current.eventsExpanded })),
|
|
1230
|
+
jumpToRandomRoom: () => replaceSession({ ...state.sessionSeed, room: uid(8) }, "Joined a new room."),
|
|
1231
|
+
commitRoom,
|
|
1232
|
+
setRoomInput: value => commit(current => ({ ...current, roomInput: value })),
|
|
1233
|
+
jumpToNewSelf: () => replaceSession({ ...state.sessionSeed, localId: cleanLocalId(uid(8)) }, "Started a fresh self ID.", { reseedBootFocus: true }),
|
|
1234
|
+
commitName,
|
|
1235
|
+
setNameInput: value => commit(current => ({ ...current, nameInput: value })),
|
|
1236
|
+
toggleSelectReadyPeers: () => {
|
|
1237
|
+
let changed = 0
|
|
1238
|
+
for (const peer of state.snapshot.peers) if (peer.presence === "active" && state.session.setPeerSelected(peer.id, peer.ready)) changed += 1
|
|
1239
|
+
commit(current => withNotice(current, { text: changed ? "Selected ready peers." : "No ready peers to select.", variant: changed ? "success" : "info" }))
|
|
1240
|
+
maybeOfferDrafts()
|
|
1241
|
+
},
|
|
1242
|
+
clearPeerSelection: () => {
|
|
1243
|
+
let changed = 0
|
|
1244
|
+
for (const peer of state.snapshot.peers) if (state.session.setPeerSelected(peer.id, false)) changed += 1
|
|
1245
|
+
commit(current => withNotice(current, { text: changed ? `Cleared ${plural(changed, "peer selection")}.` : "No peer selections to clear.", variant: changed ? "warning" : "info" }))
|
|
1246
|
+
},
|
|
1247
|
+
toggleHideTerminalPeers: () => commit(current => withNotice({ ...current, hideTerminalPeers: !current.hideTerminalPeers }, { text: current.hideTerminalPeers ? "Terminal peers shown." : "Terminal peers hidden.", variant: "info" })),
|
|
1248
|
+
togglePeer: peerId => {
|
|
1249
|
+
state.session.togglePeerSelection(peerId)
|
|
1250
|
+
maybeOfferDrafts()
|
|
1251
|
+
},
|
|
1252
|
+
toggleAutoOffer: () => {
|
|
1253
|
+
commit(current => withNotice({ ...current, autoOfferOutgoing: !current.autoOfferOutgoing }, { text: !state.autoOfferOutgoing ? "Auto-offer on." : "Auto-offer off.", variant: !state.autoOfferOutgoing ? "success" : "warning" }))
|
|
1254
|
+
maybeOfferDrafts()
|
|
1255
|
+
},
|
|
1256
|
+
toggleAutoAccept: () => {
|
|
1257
|
+
const next = !state.autoAcceptIncoming
|
|
1258
|
+
commit(current => ({ ...current, autoAcceptIncoming: next }))
|
|
1259
|
+
void state.session.setAutoAcceptIncoming(next).then(
|
|
1260
|
+
count => commit(current => withNotice(current, { text: next ? `Auto-accept on${count ? ` · accepted ${plural(count, "transfer")}` : ""}.` : "Auto-accept off.", variant: next ? "success" : "warning" })),
|
|
1261
|
+
error => commit(current => withNotice(current, { text: `${error}`, variant: "error" })),
|
|
1262
|
+
)
|
|
1263
|
+
},
|
|
1264
|
+
toggleAutoSave: () => {
|
|
1265
|
+
const next = !state.autoSaveIncoming
|
|
1266
|
+
commit(current => ({ ...current, autoSaveIncoming: next }))
|
|
1267
|
+
void state.session.setAutoSaveIncoming(next).then(
|
|
1268
|
+
count => commit(current => withNotice(current, { text: next ? `Auto-save on${count ? ` · saved ${plural(count, "transfer")}` : ""}.` : "Auto-save off.", variant: next ? "success" : "warning" })),
|
|
1269
|
+
error => commit(current => withNotice(current, { text: `${error}`, variant: "error" })),
|
|
1270
|
+
)
|
|
1271
|
+
},
|
|
1272
|
+
setDraftInput: value => updateDraftInput(value),
|
|
1273
|
+
addDrafts,
|
|
1274
|
+
removeDraft: draftId => commit(current => withNotice({ ...current, drafts: current.drafts.filter(draft => draft.id !== draftId) }, { text: "Draft removed.", variant: "warning" })),
|
|
1275
|
+
clearDrafts: () => commit(current => withNotice({ ...current, drafts: [] }, { text: current.drafts.length ? `Cleared ${plural(current.drafts.length, "draft file")}.` : "No drafts to clear.", variant: current.drafts.length ? "warning" : "info" })),
|
|
1276
|
+
cancelPendingOffers: () => {
|
|
1277
|
+
const cancelled = state.session.cancelPendingOffers()
|
|
1278
|
+
commit(current => withNotice(current, {
|
|
1279
|
+
text: cancelled ? `Cancelled ${plural(cancelled, "pending offer")}.` : "No pending offers to cancel.",
|
|
1280
|
+
variant: cancelled ? "warning" : "info",
|
|
1281
|
+
}))
|
|
1282
|
+
},
|
|
1283
|
+
acceptTransfer: transferId => {
|
|
1284
|
+
const transfer = state.snapshot.transfers.find(item => item.id === transferId)
|
|
1285
|
+
void state.session.acceptTransfer(transferId).then(ok => {
|
|
1286
|
+
commit(current => withNotice(current, { text: ok ? `Accepted ${transfer?.name ?? "transfer"}.` : `Unable to accept ${transfer?.name ?? "transfer"}.`, variant: ok ? "success" : "error" }))
|
|
1287
|
+
})
|
|
1288
|
+
},
|
|
1289
|
+
rejectTransfer: transferId => {
|
|
1290
|
+
const transfer = state.snapshot.transfers.find(item => item.id === transferId)
|
|
1291
|
+
const ok = state.session.rejectTransfer(transferId)
|
|
1292
|
+
commit(current => withNotice(current, { text: ok ? `Rejected ${transfer?.name ?? "transfer"}.` : `Unable to reject ${transfer?.name ?? "transfer"}.`, variant: ok ? "warning" : "error" }))
|
|
1293
|
+
},
|
|
1294
|
+
cancelTransfer: transferId => {
|
|
1295
|
+
const transfer = state.snapshot.transfers.find(item => item.id === transferId)
|
|
1296
|
+
const ok = state.session.cancelTransfer(transferId)
|
|
1297
|
+
commit(current => withNotice(current, { text: ok ? `Cancelled ${transfer?.name ?? "transfer"}.` : `Unable to cancel ${transfer?.name ?? "transfer"}.`, variant: ok ? "warning" : "error" }))
|
|
1298
|
+
},
|
|
1299
|
+
saveTransfer: transferId => {
|
|
1300
|
+
const transfer = state.snapshot.transfers.find(item => item.id === transferId)
|
|
1301
|
+
void state.session.saveTransfer(transferId).then(path => {
|
|
1302
|
+
commit(current => withNotice(current, { text: path ? `Saved ${transfer?.name ?? "transfer"} to ${path}.` : `Unable to save ${transfer?.name ?? "transfer"}.`, variant: path ? "success" : "error" }))
|
|
1303
|
+
})
|
|
1304
|
+
},
|
|
1305
|
+
clearCompleted: () => {
|
|
1306
|
+
const cleared = state.session.clearCompletedTransfers()
|
|
1307
|
+
commit(current => withNotice(current, { text: cleared ? `Cleared ${plural(cleared, "completed transfer")}.` : "No completed transfers to clear.", variant: cleared ? "warning" : "info" }))
|
|
1308
|
+
},
|
|
1309
|
+
clearFailed: () => {
|
|
1310
|
+
const cleared = state.session.clearFailedTransfers()
|
|
1311
|
+
commit(current => withNotice(current, { text: cleared ? `Cleared ${plural(cleared, "failed transfer")}.` : "No failed transfers to clear.", variant: cleared ? "warning" : "info" }))
|
|
1312
|
+
},
|
|
1313
|
+
clearLogs: () => {
|
|
1314
|
+
state.session.clearLogs()
|
|
1315
|
+
commit(current => withNotice(current, { text: "Events cleared.", variant: "warning" }))
|
|
1316
|
+
},
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
app.view(model => renderTuiView(model, actions))
|
|
1320
|
+
app.onFocusChange(info => {
|
|
1321
|
+
const previousFocusedId = state.focusedId
|
|
1322
|
+
commit(current => {
|
|
1323
|
+
const next = { ...current, focusedId: info.id }
|
|
1324
|
+
return info.id === current.pendingFocusTarget ? consumeSatisfiedFocusRequest(next, info.id) : next
|
|
1325
|
+
})
|
|
1326
|
+
if (previousFocusedId === DRAFT_INPUT_ID && info.id !== DRAFT_INPUT_ID) {
|
|
1327
|
+
stopPreviewSession()
|
|
1328
|
+
commit(current => ({
|
|
1329
|
+
...current,
|
|
1330
|
+
filePreview: resetFilePreview({
|
|
1331
|
+
dismissedQuery: current.filePreview.dismissedQuery === current.draftInput ? current.draftInput : null,
|
|
1332
|
+
}),
|
|
1333
|
+
}))
|
|
1334
|
+
return
|
|
1335
|
+
}
|
|
1336
|
+
if (info.id === DRAFT_INPUT_ID) {
|
|
1337
|
+
const scope = deriveFileSearchScope(state.draftInput, previewBaseRoot)
|
|
1338
|
+
if (scope && state.filePreview.dismissedQuery !== state.draftInput && (state.filePreview.pendingQuery !== scope.query || state.filePreview.workspaceRoot !== scope.workspaceRoot || state.filePreview.displayPrefix !== scope.displayPrefix)) {
|
|
1339
|
+
commit(current => ({
|
|
1340
|
+
...current,
|
|
1341
|
+
filePreview: {
|
|
1342
|
+
...(current.filePreview.workspaceRoot === scope.workspaceRoot ? current.filePreview : resetFilePreview()),
|
|
1343
|
+
workspaceRoot: scope.workspaceRoot,
|
|
1344
|
+
displayPrefix: scope.displayPrefix,
|
|
1345
|
+
pendingQuery: scope.query,
|
|
1346
|
+
waiting: true,
|
|
1347
|
+
error: null,
|
|
1348
|
+
},
|
|
1349
|
+
}))
|
|
1350
|
+
requestFilePreview(state.draftInput)
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
})
|
|
1354
|
+
app.keys({
|
|
1355
|
+
"ctrl+c": { description: "Quit", handler: requestStop },
|
|
1356
|
+
tab: {
|
|
1357
|
+
description: "Accept focused preview row",
|
|
1358
|
+
when: ctx => ctx.focusedId === DRAFT_INPUT_ID && !!selectedFilePreviewMatch(state) && filePreviewVisible(state),
|
|
1359
|
+
handler: () => {
|
|
1360
|
+
acceptSelectedFilePreview()
|
|
1361
|
+
},
|
|
1362
|
+
},
|
|
1363
|
+
up: {
|
|
1364
|
+
description: "Move file preview selection up",
|
|
1365
|
+
when: ctx => ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state) && state.filePreview.results.length > 0,
|
|
1366
|
+
handler: () => {
|
|
1367
|
+
commit(current => ({ ...current, filePreview: moveFilePreviewSelection(current.filePreview, -1) }))
|
|
1368
|
+
},
|
|
1369
|
+
},
|
|
1370
|
+
down: {
|
|
1371
|
+
description: "Move file preview selection down",
|
|
1372
|
+
when: ctx => ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state) && state.filePreview.results.length > 0,
|
|
1373
|
+
handler: () => {
|
|
1374
|
+
commit(current => ({ ...current, filePreview: moveFilePreviewSelection(current.filePreview, 1) }))
|
|
1375
|
+
},
|
|
1376
|
+
},
|
|
1377
|
+
"ctrl+p": {
|
|
1378
|
+
description: "Move file preview selection up",
|
|
1379
|
+
when: ctx => ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state) && state.filePreview.results.length > 0,
|
|
1380
|
+
handler: () => {
|
|
1381
|
+
commit(current => ({ ...current, filePreview: moveFilePreviewSelection(current.filePreview, -1) }))
|
|
1382
|
+
},
|
|
1383
|
+
},
|
|
1384
|
+
"ctrl+n": {
|
|
1385
|
+
description: "Move file preview selection down",
|
|
1386
|
+
when: ctx => ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state) && state.filePreview.results.length > 0,
|
|
1387
|
+
handler: () => {
|
|
1388
|
+
commit(current => ({ ...current, filePreview: moveFilePreviewSelection(current.filePreview, 1) }))
|
|
1389
|
+
},
|
|
1390
|
+
},
|
|
1391
|
+
enter: {
|
|
1392
|
+
description: "Commit focused input",
|
|
1393
|
+
when: ctx => ctx.focusedId === ROOM_INPUT_ID || ctx.focusedId === NAME_INPUT_ID || ctx.focusedId === DRAFT_INPUT_ID,
|
|
1394
|
+
handler: ctx => {
|
|
1395
|
+
if (ctx.focusedId === ROOM_INPUT_ID) commitRoom()
|
|
1396
|
+
if (ctx.focusedId === NAME_INPUT_ID) commitName()
|
|
1397
|
+
if (ctx.focusedId === DRAFT_INPUT_ID && !acceptSelectedFilePreview()) addDrafts()
|
|
1398
|
+
},
|
|
1399
|
+
},
|
|
1400
|
+
escape: {
|
|
1401
|
+
description: "Reset focused input",
|
|
1402
|
+
when: ctx => ctx.focusedId === ROOM_INPUT_ID || ctx.focusedId === NAME_INPUT_ID || ctx.focusedId === DRAFT_INPUT_ID,
|
|
1403
|
+
handler: ctx => {
|
|
1404
|
+
if (ctx.focusedId === ROOM_INPUT_ID) commit(current => withNotice({ ...current, roomInput: current.sessionSeed.room }, { text: "Room input reset.", variant: "warning" }))
|
|
1405
|
+
if (ctx.focusedId === NAME_INPUT_ID) commit(current => withNotice({ ...current, nameInput: visibleNameInput(current.snapshot.name) }, { text: "Name input reset.", variant: "warning" }))
|
|
1406
|
+
if (ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state)) {
|
|
1407
|
+
stopPreviewSession()
|
|
1408
|
+
commit(current => withNotice({
|
|
1409
|
+
...current,
|
|
1410
|
+
filePreview: resetFilePreview({ dismissedQuery: current.draftInput }),
|
|
1411
|
+
}, { text: "File preview hidden.", variant: "warning" }))
|
|
1412
|
+
} else if (ctx.focusedId === DRAFT_INPUT_ID) {
|
|
1413
|
+
stopPreviewSession()
|
|
1414
|
+
commit(current => withNotice({ ...current, draftInput: "", filePreview: resetFilePreview() }, { text: "Draft input cleared.", variant: "warning" }))
|
|
1415
|
+
}
|
|
1416
|
+
},
|
|
1417
|
+
},
|
|
1418
|
+
})
|
|
1419
|
+
|
|
1420
|
+
const stop = async () => {
|
|
1421
|
+
if (cleanedUp) return
|
|
1422
|
+
cleanedUp = true
|
|
1423
|
+
stopping = true
|
|
1424
|
+
unsubscribe()
|
|
1425
|
+
stopPreviewSession()
|
|
1426
|
+
await state.session.close()
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const onSignal = () => requestStop()
|
|
1430
|
+
process.once("SIGINT", onSignal)
|
|
1431
|
+
process.once("SIGTERM", onSignal)
|
|
1432
|
+
|
|
1433
|
+
try {
|
|
1434
|
+
bindSession(state.session)
|
|
1435
|
+
await app.run()
|
|
1436
|
+
} finally {
|
|
1437
|
+
process.off("SIGINT", onSignal)
|
|
1438
|
+
process.off("SIGTERM", onSignal)
|
|
1439
|
+
await stop()
|
|
1440
|
+
app.dispose()
|
|
1441
|
+
}
|
|
1442
|
+
}
|