@elefunc/send 0.1.14 → 0.1.16
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/package.json +1 -1
- package/src/core/protocol.ts +4 -1
- package/src/core/session.ts +3 -0
- package/src/tui/app.ts +299 -63
- package/src/tui/file-search.ts +4 -1
package/package.json
CHANGED
package/src/core/protocol.ts
CHANGED
|
@@ -56,6 +56,7 @@ export interface PeerProfile {
|
|
|
56
56
|
autoAcceptIncoming?: boolean
|
|
57
57
|
autoSaveIncoming?: boolean
|
|
58
58
|
}
|
|
59
|
+
streamingSaveIncoming?: boolean
|
|
59
60
|
ready?: boolean
|
|
60
61
|
error?: string
|
|
61
62
|
}
|
|
@@ -219,13 +220,15 @@ export const signalEpoch = (value: unknown) => Number.isSafeInteger(value) && Nu
|
|
|
219
220
|
|
|
220
221
|
export const buildCliProfile = (): PeerProfile => ({
|
|
221
222
|
ua: { browser: "send-cli", os: process.platform, device: "desktop" },
|
|
223
|
+
streamingSaveIncoming: true,
|
|
222
224
|
ready: true,
|
|
223
225
|
})
|
|
224
226
|
|
|
225
227
|
export const peerDefaultsToken = (profile?: PeerProfile) => {
|
|
226
228
|
const autoAcceptIncoming = typeof profile?.defaults?.autoAcceptIncoming === "boolean" ? profile.defaults.autoAcceptIncoming : null
|
|
227
229
|
const autoSaveIncoming = typeof profile?.defaults?.autoSaveIncoming === "boolean" ? profile.defaults.autoSaveIncoming : null
|
|
228
|
-
|
|
230
|
+
if (autoAcceptIncoming === null || autoSaveIncoming === null) return "??"
|
|
231
|
+
return `${autoAcceptIncoming ? "A" : "a"}${!autoSaveIncoming ? "s" : profile?.streamingSaveIncoming === true ? "X" : "S"}`
|
|
229
232
|
}
|
|
230
233
|
|
|
231
234
|
export const displayPeerName = (name: string, id: string) => `${cleanName(name)}-${id}`
|
package/src/core/session.ts
CHANGED
|
@@ -230,6 +230,7 @@ export const sanitizeProfile = (profile?: PeerProfile): PeerProfile => ({
|
|
|
230
230
|
autoAcceptIncoming: typeof profile?.defaults?.autoAcceptIncoming === "boolean" ? profile.defaults.autoAcceptIncoming : undefined,
|
|
231
231
|
autoSaveIncoming: typeof profile?.defaults?.autoSaveIncoming === "boolean" ? profile.defaults.autoSaveIncoming : undefined,
|
|
232
232
|
},
|
|
233
|
+
streamingSaveIncoming: typeof profile?.streamingSaveIncoming === "boolean" ? profile.streamingSaveIncoming : undefined,
|
|
233
234
|
ready: !!profile?.ready,
|
|
234
235
|
error: cleanText(profile?.error, 120),
|
|
235
236
|
})
|
|
@@ -254,6 +255,7 @@ export const localProfileFromResponse = (data: unknown, error = ""): PeerProfile
|
|
|
254
255
|
ip: cleaned(value?.hs?.["cf-connecting-ip"] || value?.hs?.["x-real-ip"], 80),
|
|
255
256
|
},
|
|
256
257
|
ua: buildCliProfile().ua,
|
|
258
|
+
streamingSaveIncoming: true,
|
|
257
259
|
ready: !!value?.cf,
|
|
258
260
|
error,
|
|
259
261
|
})
|
|
@@ -1175,6 +1177,7 @@ export class SendSession {
|
|
|
1175
1177
|
autoAcceptIncoming: this.autoAcceptIncoming,
|
|
1176
1178
|
autoSaveIncoming: this.autoSaveIncoming,
|
|
1177
1179
|
},
|
|
1180
|
+
streamingSaveIncoming: true,
|
|
1178
1181
|
})
|
|
1179
1182
|
}
|
|
1180
1183
|
|
package/src/tui/app.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolve } from "node:path"
|
|
2
|
-
import { rgb, ui, type BadgeVariant, type VNode } from "@rezi-ui/core"
|
|
2
|
+
import { BACKEND_RAW_WRITE_MARKER, rgb, ui, type BackendRawWrite, type BadgeVariant, type TextStyle, type UiEvent, type VNode } from "@rezi-ui/core"
|
|
3
3
|
import { createNodeApp } from "@rezi-ui/node"
|
|
4
|
+
import { applyInputEditEvent } from "../../node_modules/@rezi-ui/core/dist/runtime/inputEditor.js"
|
|
4
5
|
import { inspectLocalFile } from "../core/files"
|
|
5
6
|
import { isSessionAbortedError, SendSession, type PeerSnapshot, type SessionConfig, type SessionSnapshot, type TransferSnapshot } from "../core/session"
|
|
6
7
|
import { cleanLocalId, cleanName, cleanRoom, displayPeerName, fallbackName, formatBytes, type LogEntry, peerDefaultsToken, type PeerProfile, uid } from "../core/protocol"
|
|
@@ -16,6 +17,13 @@ type TransferSection = { title: string; items: TransferSnapshot[]; clearAction?:
|
|
|
16
17
|
type TransferSummaryStat = { state: string; label?: string; count: number; size: number; countText?: string; sizeText?: string }
|
|
17
18
|
type TransferGroup = { key: string; name: string; items: TransferSnapshot[] }
|
|
18
19
|
type DenseSectionChild = VNode | false | null | undefined
|
|
20
|
+
type PreviewSegmentRole = "prefix" | "path" | "basename"
|
|
21
|
+
type PreviewSegment = { text: string; highlighted: boolean; role: PreviewSegmentRole }
|
|
22
|
+
type DraftHistoryState = {
|
|
23
|
+
entries: string[]
|
|
24
|
+
index: number | null
|
|
25
|
+
baseInput: string | null
|
|
26
|
+
}
|
|
19
27
|
type FilePreviewState = {
|
|
20
28
|
dismissedQuery: string | null
|
|
21
29
|
workspaceRoot: string | null
|
|
@@ -48,6 +56,7 @@ export interface TuiState {
|
|
|
48
56
|
peerSelectionByRoom: Map<string, Map<string, boolean>>
|
|
49
57
|
snapshot: SessionSnapshot
|
|
50
58
|
aboutOpen: boolean
|
|
59
|
+
inviteDropdownOpen: boolean
|
|
51
60
|
peerSearch: string
|
|
52
61
|
focusedId: string | null
|
|
53
62
|
roomInput: string
|
|
@@ -57,6 +66,7 @@ export interface TuiState {
|
|
|
57
66
|
bootNameJumpPending: boolean
|
|
58
67
|
draftInput: string
|
|
59
68
|
draftInputKeyVersion: number
|
|
69
|
+
draftHistory: DraftHistoryState
|
|
60
70
|
filePreview: FilePreviewState
|
|
61
71
|
drafts: DraftItem[]
|
|
62
72
|
autoOfferOutgoing: boolean
|
|
@@ -72,6 +82,10 @@ export interface TuiActions {
|
|
|
72
82
|
toggleEvents: TuiAction
|
|
73
83
|
openAbout: TuiAction
|
|
74
84
|
closeAbout: TuiAction
|
|
85
|
+
toggleInviteDropdown: TuiAction
|
|
86
|
+
closeInviteDropdown: TuiAction
|
|
87
|
+
copyWebInvite: TuiAction
|
|
88
|
+
copyCliInvite: TuiAction
|
|
75
89
|
jumpToRandomRoom: TuiAction
|
|
76
90
|
commitRoom: TuiAction
|
|
77
91
|
setRoomInput: (value: string) => void
|
|
@@ -88,7 +102,7 @@ export interface TuiActions {
|
|
|
88
102
|
toggleAutoOffer: TuiAction
|
|
89
103
|
toggleAutoAccept: TuiAction
|
|
90
104
|
toggleAutoSave: TuiAction
|
|
91
|
-
setDraftInput: (value: string) => void
|
|
105
|
+
setDraftInput: (value: string, cursor?: number) => void
|
|
92
106
|
addDrafts: TuiAction
|
|
93
107
|
removeDraft: (draftId: string) => void
|
|
94
108
|
clearDrafts: TuiAction
|
|
@@ -106,20 +120,22 @@ const ROOM_INPUT_ID = "room-input"
|
|
|
106
120
|
const NAME_INPUT_ID = "name-input"
|
|
107
121
|
const PEER_SEARCH_INPUT_ID = "peer-search-input"
|
|
108
122
|
const DRAFT_INPUT_ID = "draft-input"
|
|
123
|
+
const ROOM_INVITE_BUTTON_ID = "room-invite-button"
|
|
124
|
+
const INVITE_DROPDOWN_ID = "room-invite-dropdown"
|
|
109
125
|
const ABOUT_TRIGGER_ID = "open-about"
|
|
110
126
|
const TRANSPARENT_BORDER_STYLE = { fg: rgb(7, 10, 12) } as const
|
|
111
127
|
const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
|
|
112
128
|
const PRIMARY_TEXT_STYLE = { fg: rgb(255, 255, 255) } as const
|
|
113
129
|
const HEADING_TEXT_STYLE = { fg: rgb(255, 255, 255), bold: true } as const
|
|
114
|
-
const
|
|
130
|
+
const MUTED_TEXT_STYLE = { fg: rgb(159, 166, 178) } as const
|
|
131
|
+
const DEFAULT_WEB_URL = "https://rtme.sh/"
|
|
115
132
|
const DEFAULT_SAVE_DIR = resolve(process.cwd())
|
|
116
133
|
const ABOUT_ELEFUNC_URL = "https://elefunc.com/send"
|
|
117
134
|
const ABOUT_TITLE = "About Send"
|
|
118
135
|
const ABOUT_INTRO = "Peer-to-Peer Transfers – Web & CLI"
|
|
119
136
|
const ABOUT_SUMMARY = "Join the same room, see who is there, and offer files directly to selected peers."
|
|
120
137
|
const ABOUT_RUNTIME = "Send uses lightweight signaling to discover peers and negotiate WebRTC. Files move over WebRTC data channels, using direct paths when possible and TURN relay when needed."
|
|
121
|
-
const
|
|
122
|
-
const ABOUT_WEB_LINK_LABEL = "Web"
|
|
138
|
+
const COPY_SERVICE_URL = "https://copy.rt.ht/"
|
|
123
139
|
const TRANSFER_DIRECTION_ARROW = {
|
|
124
140
|
out: { glyph: "↗", style: { fg: rgb(170, 217, 76), bold: true } },
|
|
125
141
|
in: { glyph: "↙", style: { fg: rgb(240, 113, 120), bold: true } },
|
|
@@ -129,6 +145,7 @@ const countFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0
|
|
|
129
145
|
const percentFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
|
|
130
146
|
const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
|
131
147
|
const pluralRules = new Intl.PluralRules()
|
|
148
|
+
const DRAFT_HISTORY_LIMIT = 50
|
|
132
149
|
|
|
133
150
|
export const visiblePanes = (showEvents: boolean): VisiblePane[] => showEvents ? ["peers", "transfers", "logs"] : ["peers", "transfers"]
|
|
134
151
|
|
|
@@ -146,6 +163,12 @@ type ProcessSignalLike = {
|
|
|
146
163
|
off?: (signal: TuiQuitSignal, handler: () => void) => unknown
|
|
147
164
|
removeListener?: (signal: TuiQuitSignal, handler: () => void) => unknown
|
|
148
165
|
}
|
|
166
|
+
type BunSpawn = (cmd: string[], options: {
|
|
167
|
+
stdin?: "pipe" | "inherit" | "ignore"
|
|
168
|
+
stdout?: "pipe" | "inherit" | "ignore"
|
|
169
|
+
stderr?: "pipe" | "inherit" | "ignore"
|
|
170
|
+
}) => { unref?: () => void }
|
|
171
|
+
type BunLike = { spawn?: BunSpawn }
|
|
149
172
|
type ShareUrlState = Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming">
|
|
150
173
|
type ShareCliState = ShareUrlState & Pick<TuiState, "sessionSeed" | "eventsExpanded">
|
|
151
174
|
const shellQuote = (value: string) => safeShellArgPattern.test(value) ? value : `'${value.replaceAll("'", `'\"'\"'`)}'`
|
|
@@ -192,8 +215,29 @@ const renderWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL, omitDefau
|
|
|
192
215
|
url.hash = buildHashParams(state, omitDefaults).toString()
|
|
193
216
|
return url.toString()
|
|
194
217
|
}
|
|
218
|
+
const schemeLessUrlText = (text: string) => text.replace(/^[a-z]+:\/\//, "")
|
|
219
|
+
export const inviteWebLabel = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => schemeLessUrlText(webInviteUrl(state, baseUrl))
|
|
220
|
+
export const inviteCliPackageName = (baseUrl = resolveWebUrlBase()) => new URL(resolveWebUrlBase(baseUrl)).hostname
|
|
221
|
+
export const inviteCliCommand = (state: ShareUrlState) => {
|
|
222
|
+
const args: string[] = []
|
|
223
|
+
appendCliFlag(args, "--room", state.snapshot.room)
|
|
224
|
+
appendToggleCliFlags(args, state)
|
|
225
|
+
return args.join(" ")
|
|
226
|
+
}
|
|
227
|
+
export const inviteCliText = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => `bunx ${inviteCliPackageName(baseUrl)} ${inviteCliCommand(state)}`
|
|
228
|
+
export const inviteCopyUrl = (text: string) => `${COPY_SERVICE_URL}#${new URLSearchParams({ text })}`
|
|
229
|
+
export const buildOsc52ClipboardSequence = (text: string) => text ? `\u001b]52;c;${Buffer.from(text).toString("base64")}\u0007` : ""
|
|
230
|
+
export const externalOpenCommand = (url: string, platform = process.platform) =>
|
|
231
|
+
platform === "darwin" ? ["open", url]
|
|
232
|
+
: platform === "win32" ? ["cmd.exe", "/c", "start", "", url]
|
|
233
|
+
: ["xdg-open", url]
|
|
234
|
+
const getBackendRawWriter = (backend: unknown): BackendRawWrite | null => {
|
|
235
|
+
const marker = (backend as Record<string, unknown>)[BACKEND_RAW_WRITE_MARKER]
|
|
236
|
+
return typeof marker === "function" ? marker as BackendRawWrite : null
|
|
237
|
+
}
|
|
238
|
+
const getBunRuntime = () => (globalThis as typeof globalThis & { Bun?: BunLike }).Bun ?? null
|
|
195
239
|
const renderCliCommand = (state: ShareCliState, { includeSelf = false, includePrefix = false } = {}) => {
|
|
196
|
-
const args = includePrefix ? ["bunx", "
|
|
240
|
+
const args = includePrefix ? ["bunx", "rtme.sh"] : []
|
|
197
241
|
appendCliFlag(args, "--room", state.snapshot.room)
|
|
198
242
|
if (includeSelf) appendCliFlag(args, "--self", displayPeerName(state.snapshot.name, state.snapshot.localId))
|
|
199
243
|
appendToggleCliFlags(args, state)
|
|
@@ -256,6 +300,10 @@ export const createNoopTuiActions = (): TuiActions => ({
|
|
|
256
300
|
toggleEvents: noop,
|
|
257
301
|
openAbout: noop,
|
|
258
302
|
closeAbout: noop,
|
|
303
|
+
toggleInviteDropdown: noop,
|
|
304
|
+
closeInviteDropdown: noop,
|
|
305
|
+
copyWebInvite: noop,
|
|
306
|
+
copyCliInvite: noop,
|
|
259
307
|
jumpToRandomRoom: noop,
|
|
260
308
|
commitRoom: noop,
|
|
261
309
|
setRoomInput: noop,
|
|
@@ -380,7 +428,7 @@ const uaSummary = (profile?: PeerProfile) => joinSummary([profile?.ua?.browser,
|
|
|
380
428
|
const profileIp = (profile?: PeerProfile) => profile?.network?.ip || "—"
|
|
381
429
|
const peerDefaultsVariant = (profile?: PeerProfile): BadgeVariant => {
|
|
382
430
|
const token = peerDefaultsToken(profile)
|
|
383
|
-
return token === "
|
|
431
|
+
return token === "AX" ? "success" : token === "as" ? "warning" : token === "??" ? "default" : "info"
|
|
384
432
|
}
|
|
385
433
|
const TIGHT_TAG_COLORS = {
|
|
386
434
|
default: rgb(89, 194, 255),
|
|
@@ -389,6 +437,12 @@ const TIGHT_TAG_COLORS = {
|
|
|
389
437
|
error: rgb(240, 113, 120),
|
|
390
438
|
info: rgb(89, 194, 255),
|
|
391
439
|
} as const
|
|
440
|
+
const PREVIEW_PREFIX_STYLE = { fg: rgb(112, 121, 136), dim: true } as const
|
|
441
|
+
const PREVIEW_PATH_STYLE = { ...MUTED_TEXT_STYLE, dim: true } as const
|
|
442
|
+
const PREVIEW_BASENAME_STYLE = PRIMARY_TEXT_STYLE
|
|
443
|
+
const PREVIEW_HIGHLIGHT_STYLE = { fg: TIGHT_TAG_COLORS.info, bold: true } as const
|
|
444
|
+
const PREVIEW_SELECTED_HIGHLIGHT_STYLE = { fg: TIGHT_TAG_COLORS.success, bold: true } as const
|
|
445
|
+
const PREVIEW_SELECTED_MARKER_STYLE = { fg: TIGHT_TAG_COLORS.info, bold: true } as const
|
|
392
446
|
const tightTag = (text: string, props: { key?: string; variant?: BadgeVariant; bare?: boolean } = {}) => ui.text(props.bare ? text : `(${text})`, {
|
|
393
447
|
...(props.key === undefined ? {} : { key: props.key }),
|
|
394
448
|
style: {
|
|
@@ -408,6 +462,71 @@ const emptyFilePreviewState = (): FilePreviewState => ({
|
|
|
408
462
|
selectedIndex: null,
|
|
409
463
|
scrollTop: 0,
|
|
410
464
|
})
|
|
465
|
+
const emptyDraftHistoryState = (): DraftHistoryState => ({
|
|
466
|
+
entries: [],
|
|
467
|
+
index: null,
|
|
468
|
+
baseInput: null,
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
const resetDraftHistoryBrowse = (history: DraftHistoryState): DraftHistoryState =>
|
|
472
|
+
history.index == null && history.baseInput == null
|
|
473
|
+
? history
|
|
474
|
+
: { ...history, index: null, baseInput: null }
|
|
475
|
+
|
|
476
|
+
export const pushDraftHistoryEntry = (history: DraftHistoryState, value: string, limit = DRAFT_HISTORY_LIMIT): DraftHistoryState => {
|
|
477
|
+
const nextValue = normalizeSearchQuery(value)
|
|
478
|
+
if (!nextValue) return resetDraftHistoryBrowse(history)
|
|
479
|
+
return {
|
|
480
|
+
entries: history.entries[0] === nextValue ? history.entries : [nextValue, ...history.entries].slice(0, limit),
|
|
481
|
+
index: null,
|
|
482
|
+
baseInput: null,
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export const isDraftHistoryEntryPoint = (value: string, cursor: number, cwd = process.cwd()) => {
|
|
487
|
+
if (cursor !== 0) return false
|
|
488
|
+
const normalized = normalizeSearchQuery(value)
|
|
489
|
+
if (!normalized) return true
|
|
490
|
+
return !deriveFileSearchScope(value, cwd)?.query
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export const canNavigateDraftHistory = (history: DraftHistoryState, value: string, cursor: number, cwd = process.cwd()) =>
|
|
494
|
+
cursor === 0 && (history.index != null || (history.entries.length > 0 && isDraftHistoryEntryPoint(value, cursor, cwd)))
|
|
495
|
+
|
|
496
|
+
export const moveDraftHistory = (history: DraftHistoryState, value: string, direction: -1 | 1) => {
|
|
497
|
+
if (!history.entries.length) return { history, value, changed: false }
|
|
498
|
+
if (history.index == null) {
|
|
499
|
+
if (direction > 0) return { history, value, changed: false }
|
|
500
|
+
const nextIndex = 0
|
|
501
|
+
return {
|
|
502
|
+
history: { ...history, index: nextIndex, baseInput: value },
|
|
503
|
+
value: history.entries[nextIndex]!,
|
|
504
|
+
changed: history.entries[nextIndex] !== value,
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (direction < 0) {
|
|
508
|
+
const nextIndex = Math.min(history.entries.length - 1, history.index + 1)
|
|
509
|
+
return {
|
|
510
|
+
history: nextIndex === history.index ? history : { ...history, index: nextIndex },
|
|
511
|
+
value: history.entries[nextIndex]!,
|
|
512
|
+
changed: nextIndex !== history.index || history.entries[nextIndex] !== value,
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (history.index === 0) {
|
|
516
|
+
const nextValue = history.baseInput ?? ""
|
|
517
|
+
return {
|
|
518
|
+
history: resetDraftHistoryBrowse(history),
|
|
519
|
+
value: nextValue,
|
|
520
|
+
changed: nextValue !== value || history.index != null,
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const nextIndex = history.index - 1
|
|
524
|
+
return {
|
|
525
|
+
history: { ...history, index: nextIndex },
|
|
526
|
+
value: history.entries[nextIndex]!,
|
|
527
|
+
changed: history.entries[nextIndex] !== value,
|
|
528
|
+
}
|
|
529
|
+
}
|
|
411
530
|
|
|
412
531
|
type FocusControllerState = Pick<TuiState, "pendingFocusTarget" | "focusRequestEpoch" | "bootNameJumpPending">
|
|
413
532
|
|
|
@@ -493,6 +612,12 @@ export const transferActualDurationMs = (transfer: TransferSnapshot, now = Date.
|
|
|
493
612
|
export const transferWaitDurationMs = (transfer: TransferSnapshot, now = Date.now()) => Math.max(0, (transfer.startedAt || now) - transfer.createdAt)
|
|
494
613
|
export const filePreviewVisible = (state: Pick<TuiState, "focusedId" | "draftInput" | "filePreview">) =>
|
|
495
614
|
state.focusedId === DRAFT_INPUT_ID && !!normalizeSearchQuery(state.draftInput) && state.filePreview.dismissedQuery !== state.draftInput
|
|
615
|
+
export const canAcceptFilePreviewWithRight = (state: Pick<TuiState, "focusedId" | "draftInput" | "filePreview">, cursor: number) =>
|
|
616
|
+
state.focusedId === DRAFT_INPUT_ID
|
|
617
|
+
&& cursor === state.draftInput.length
|
|
618
|
+
&& filePreviewVisible(state)
|
|
619
|
+
&& state.filePreview.selectedIndex != null
|
|
620
|
+
&& !!state.filePreview.results[state.filePreview.selectedIndex]
|
|
496
621
|
export const clampFilePreviewSelectedIndex = (selectedIndex: number | null, resultCount: number) =>
|
|
497
622
|
!resultCount ? null : selectedIndex == null ? 0 : Math.max(0, Math.min(resultCount - 1, selectedIndex))
|
|
498
623
|
export const ensureFilePreviewScrollTop = (selectedIndex: number | null, scrollTop: number, resultCount: number, visibleRows = FILE_SEARCH_VISIBLE_ROWS) => {
|
|
@@ -519,24 +644,36 @@ const selectedFilePreviewMatch = (state: TuiState) => {
|
|
|
519
644
|
const index = state.filePreview.selectedIndex
|
|
520
645
|
return index == null ? null : state.filePreview.results[index] ?? null
|
|
521
646
|
}
|
|
522
|
-
const
|
|
647
|
+
export const previewPathSegments = (value: string, prefixLength: number, indices: number[]) => {
|
|
523
648
|
const marks = new Set(indices)
|
|
524
649
|
const chars = Array.from(value)
|
|
525
|
-
const
|
|
650
|
+
const basenameStart = Math.max(prefixLength, value.lastIndexOf("/") + 1)
|
|
651
|
+
const segments: PreviewSegment[] = []
|
|
526
652
|
let current = ""
|
|
527
653
|
let highlighted = false
|
|
654
|
+
let role: PreviewSegmentRole = "basename"
|
|
528
655
|
for (let index = 0; index < chars.length; index += 1) {
|
|
529
656
|
const nextHighlighted = marks.has(index)
|
|
530
|
-
|
|
531
|
-
|
|
657
|
+
const nextRole = index < prefixLength ? "prefix" : index < basenameStart ? "path" : "basename"
|
|
658
|
+
if (current && (nextHighlighted !== highlighted || nextRole !== role)) {
|
|
659
|
+
segments.push({ text: current, highlighted, role })
|
|
532
660
|
current = ""
|
|
533
661
|
}
|
|
534
662
|
current += chars[index]
|
|
535
663
|
highlighted = nextHighlighted
|
|
664
|
+
role = nextRole
|
|
536
665
|
}
|
|
537
|
-
if (current) segments.push({ text: current, highlighted })
|
|
666
|
+
if (current) segments.push({ text: current, highlighted, role })
|
|
538
667
|
return segments
|
|
539
668
|
}
|
|
669
|
+
export const previewSegmentStyle = (segment: PreviewSegment, selected: boolean): TextStyle =>
|
|
670
|
+
segment.highlighted
|
|
671
|
+
? selected ? PREVIEW_SELECTED_HIGHLIGHT_STYLE : PREVIEW_HIGHLIGHT_STYLE
|
|
672
|
+
: segment.role === "prefix"
|
|
673
|
+
? PREVIEW_PREFIX_STYLE
|
|
674
|
+
: segment.role === "path"
|
|
675
|
+
? PREVIEW_PATH_STYLE
|
|
676
|
+
: PREVIEW_BASENAME_STYLE
|
|
540
677
|
|
|
541
678
|
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[] => {
|
|
542
679
|
const order = ["draft", "pending", "queued", "offered", "accepted", "receiving", "sending", "awaiting-done", "cancelling", "complete", "rejected", "cancelled", "error"]
|
|
@@ -602,6 +739,7 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
|
|
|
602
739
|
peerSelectionByRoom,
|
|
603
740
|
snapshot: session.snapshot(),
|
|
604
741
|
aboutOpen: false,
|
|
742
|
+
inviteDropdownOpen: false,
|
|
605
743
|
peerSearch: "",
|
|
606
744
|
focusedId: null,
|
|
607
745
|
roomInput: sessionSeed.room,
|
|
@@ -609,6 +747,7 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
|
|
|
609
747
|
...focusState,
|
|
610
748
|
draftInput: "",
|
|
611
749
|
draftInputKeyVersion: 0,
|
|
750
|
+
draftHistory: emptyDraftHistoryState(),
|
|
612
751
|
filePreview: emptyFilePreviewState(),
|
|
613
752
|
drafts: [],
|
|
614
753
|
autoOfferOutgoing: launchOptions.offer ?? true,
|
|
@@ -709,12 +848,7 @@ const renderHeader = (state: TuiState, actions: TuiActions) => denseSection({
|
|
|
709
848
|
],
|
|
710
849
|
}, [])
|
|
711
850
|
|
|
712
|
-
const renderAboutModal = (
|
|
713
|
-
const cliCommand = aboutCliCommand(state)
|
|
714
|
-
const cliCopyText = `${ABOUT_CLI_LABEL} ${cliCommand}`
|
|
715
|
-
const cliCopyUrl = `https://copy.rt.ht/#${new URLSearchParams({ text: cliCopyText })}`
|
|
716
|
-
const currentWebUrl = aboutWebUrl(state)
|
|
717
|
-
const currentWebLabel = aboutWebLabel(state)
|
|
851
|
+
const renderAboutModal = (_state: TuiState, actions: TuiActions) => {
|
|
718
852
|
return ui.modal({
|
|
719
853
|
id: "about-modal",
|
|
720
854
|
title: ABOUT_TITLE,
|
|
@@ -722,24 +856,6 @@ const renderAboutModal = (state: TuiState, actions: TuiActions) => {
|
|
|
722
856
|
ui.text(ABOUT_INTRO, { id: "about-intro", variant: "heading", wrap: true }),
|
|
723
857
|
ui.text(ABOUT_SUMMARY, { id: "about-summary", wrap: true }),
|
|
724
858
|
ui.text(ABOUT_RUNTIME, { id: "about-runtime", wrap: true }),
|
|
725
|
-
ui.column({ gap: 0 }, [
|
|
726
|
-
ui.text(ABOUT_CLI_LABEL, { id: "about-cli-label", variant: "caption", wrap: true }),
|
|
727
|
-
ui.link({
|
|
728
|
-
id: "about-current-cli",
|
|
729
|
-
label: cliCommand,
|
|
730
|
-
accessibleLabel: "Copy current CLI command",
|
|
731
|
-
url: cliCopyUrl,
|
|
732
|
-
}),
|
|
733
|
-
]),
|
|
734
|
-
ui.column({ gap: 0 }, [
|
|
735
|
-
ui.text(ABOUT_WEB_LINK_LABEL, { id: "about-web-link-label", variant: "caption", wrap: true }),
|
|
736
|
-
ui.link({
|
|
737
|
-
id: "about-current-web-link",
|
|
738
|
-
label: currentWebLabel,
|
|
739
|
-
accessibleLabel: "Open current web link",
|
|
740
|
-
url: currentWebUrl,
|
|
741
|
-
}),
|
|
742
|
-
]),
|
|
743
859
|
]),
|
|
744
860
|
actions: [
|
|
745
861
|
ui.link({
|
|
@@ -761,6 +877,18 @@ const renderAboutModal = (state: TuiState, actions: TuiActions) => {
|
|
|
761
877
|
})
|
|
762
878
|
}
|
|
763
879
|
|
|
880
|
+
const renderInviteDropdown = (state: TuiState, actions: TuiActions) => ui.dropdown({
|
|
881
|
+
id: INVITE_DROPDOWN_ID,
|
|
882
|
+
anchorId: ROOM_INVITE_BUTTON_ID,
|
|
883
|
+
position: "below-end",
|
|
884
|
+
items: [
|
|
885
|
+
{ id: "web", label: "WEB", shortcut: inviteWebLabel(state) },
|
|
886
|
+
{ id: "cli", label: "CLI", shortcut: inviteCliText(state) },
|
|
887
|
+
],
|
|
888
|
+
onSelect: item => { if (item.id === "web") actions.copyWebInvite(); if (item.id === "cli") actions.copyCliInvite() },
|
|
889
|
+
onClose: actions.closeInviteDropdown,
|
|
890
|
+
})
|
|
891
|
+
|
|
764
892
|
const renderRoomCard = (state: TuiState, actions: TuiActions) => denseSection({
|
|
765
893
|
id: "room-card",
|
|
766
894
|
}, [
|
|
@@ -776,11 +904,13 @@ const renderRoomCard = (state: TuiState, actions: TuiActions) => denseSection({
|
|
|
776
904
|
}),
|
|
777
905
|
]),
|
|
778
906
|
ui.row({ id: "room-invite-slot", width: 6, justify: "center", items: "center" }, [
|
|
779
|
-
ui.
|
|
780
|
-
id:
|
|
907
|
+
ui.button({
|
|
908
|
+
id: ROOM_INVITE_BUTTON_ID,
|
|
781
909
|
label: "📋",
|
|
782
|
-
accessibleLabel: "Open invite
|
|
783
|
-
|
|
910
|
+
accessibleLabel: "Open invite links",
|
|
911
|
+
onPress: actions.toggleInviteDropdown,
|
|
912
|
+
dsVariant: "ghost",
|
|
913
|
+
intent: "secondary",
|
|
784
914
|
}),
|
|
785
915
|
]),
|
|
786
916
|
]),
|
|
@@ -939,14 +1069,14 @@ const renderDraftSummary = (drafts: DraftItem[]) => denseSection({
|
|
|
939
1069
|
ui.row({ gap: 1, wrap: true }, summarizeStates(drafts, () => "draft", draft => draft.size, ["draft"]).map(renderSummaryStat)),
|
|
940
1070
|
])
|
|
941
1071
|
|
|
942
|
-
const renderHighlightedPreviewPath = (value: string, indices: number[], options: { id?: string; key?: string; flex?: number } = {}) => ui.row({
|
|
1072
|
+
const renderHighlightedPreviewPath = (value: string, prefixLength: number, indices: number[], selected: boolean, options: { id?: string; key?: string; flex?: number } = {}) => ui.row({
|
|
943
1073
|
gap: 0,
|
|
944
1074
|
wrap: true,
|
|
945
1075
|
...(options.id === undefined ? {} : { id: options.id }),
|
|
946
1076
|
...(options.key === undefined ? {} : { key: options.key }),
|
|
947
1077
|
...(options.flex === undefined ? {} : { flex: options.flex }),
|
|
948
|
-
},
|
|
949
|
-
ui.text(segment.text, { key: `segment-${index}`,
|
|
1078
|
+
}, previewPathSegments(value, prefixLength, indices).map((segment, index) =>
|
|
1079
|
+
ui.text(segment.text, { key: `segment-${index}`, style: previewSegmentStyle(segment, selected) }),
|
|
950
1080
|
))
|
|
951
1081
|
|
|
952
1082
|
const renderFilePreviewRow = (match: FileSearchMatch, index: number, selected: boolean, displayPrefix: string) => ui.row({
|
|
@@ -955,10 +1085,12 @@ const renderFilePreviewRow = (match: FileSearchMatch, index: number, selected: b
|
|
|
955
1085
|
gap: 1,
|
|
956
1086
|
wrap: true,
|
|
957
1087
|
}, [
|
|
958
|
-
ui.text(selected ? ">" : " "),
|
|
1088
|
+
ui.text(selected ? ">" : " ", { style: selected ? PREVIEW_SELECTED_MARKER_STYLE : PREVIEW_PATH_STYLE }),
|
|
959
1089
|
renderHighlightedPreviewPath(
|
|
960
1090
|
formatFileSearchDisplayPath(displayPrefix, match.relativePath),
|
|
1091
|
+
Array.from(formatFileSearchDisplayPath(displayPrefix, "")).length,
|
|
961
1092
|
offsetFileSearchMatchIndices(displayPrefix, match.indices),
|
|
1093
|
+
selected,
|
|
962
1094
|
{ id: `file-preview-path-${index}`, flex: 1 },
|
|
963
1095
|
),
|
|
964
1096
|
match.kind === "file" && typeof match.size === "number" ? ui.text(formatBytes(match.size), { style: { dim: true } }) : null,
|
|
@@ -1108,7 +1240,7 @@ const renderFilesCard = (state: TuiState, actions: TuiActions) => denseSection({
|
|
|
1108
1240
|
key: `draft-input-${state.draftInputKeyVersion}`,
|
|
1109
1241
|
value: state.draftInput,
|
|
1110
1242
|
placeholder: "path/to/file.txt",
|
|
1111
|
-
onInput: value => actions.setDraftInput(value),
|
|
1243
|
+
onInput: (value, cursor) => actions.setDraftInput(value, cursor),
|
|
1112
1244
|
}),
|
|
1113
1245
|
]),
|
|
1114
1246
|
actionButton("add-drafts", "Add", actions.addDrafts, "primary", !state.draftInput.trim()),
|
|
@@ -1202,9 +1334,12 @@ export const renderTuiView = (state: TuiState, actions: TuiActions): VNode => {
|
|
|
1202
1334
|
}, [page])
|
|
1203
1335
|
: page
|
|
1204
1336
|
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
:
|
|
1337
|
+
const overlays = [
|
|
1338
|
+
state.inviteDropdownOpen && !state.aboutOpen ? renderInviteDropdown(state, actions) : null,
|
|
1339
|
+
state.aboutOpen ? renderAboutModal(state, actions) : null,
|
|
1340
|
+
].filter((overlay): overlay is VNode => !!overlay)
|
|
1341
|
+
|
|
1342
|
+
return overlays.length ? ui.layers([basePage, ...overlays]) : basePage
|
|
1208
1343
|
}
|
|
1209
1344
|
|
|
1210
1345
|
const withNotice = (state: TuiState, notice: Notice): TuiState => ({ ...state, notice })
|
|
@@ -1214,6 +1349,7 @@ export const withAcceptedDraftInput = (state: TuiState, draftInput: string, file
|
|
|
1214
1349
|
...state,
|
|
1215
1350
|
draftInput,
|
|
1216
1351
|
draftInputKeyVersion: state.draftInputKeyVersion + 1,
|
|
1352
|
+
draftHistory: resetDraftHistoryBrowse(state.draftHistory),
|
|
1217
1353
|
filePreview,
|
|
1218
1354
|
pendingFocusTarget: DRAFT_INPUT_ID,
|
|
1219
1355
|
focusRequestEpoch: state.focusRequestEpoch + 1,
|
|
@@ -1233,6 +1369,40 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1233
1369
|
let previewWorker: Worker | null = null
|
|
1234
1370
|
let previewSessionId: string | null = null
|
|
1235
1371
|
let previewSessionRoot: string | null = null
|
|
1372
|
+
let draftCursor = state.draftInput.length
|
|
1373
|
+
let draftCursorBeforeEvent = draftCursor
|
|
1374
|
+
let osc52SupportPromise: Promise<boolean> | null = null
|
|
1375
|
+
|
|
1376
|
+
const ensureOsc52Support = () => osc52SupportPromise ??= app.backend.getCaps().then(caps => caps.supportsOsc52, () => false)
|
|
1377
|
+
const openExternalUrl = (url: string) => {
|
|
1378
|
+
try {
|
|
1379
|
+
const bun = getBunRuntime()
|
|
1380
|
+
const child = bun?.spawn?.(externalOpenCommand(url), { stdin: "ignore", stdout: "ignore", stderr: "ignore" })
|
|
1381
|
+
if (!child) return false
|
|
1382
|
+
child.unref?.()
|
|
1383
|
+
return true
|
|
1384
|
+
} catch {
|
|
1385
|
+
return false
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
const copyInvitePayload = async (payload: string, label: "WEB" | "CLI") => {
|
|
1389
|
+
const closeDropdown = (notice: Notice) => commit(current => withNotice({ ...current, inviteDropdownOpen: false }, notice))
|
|
1390
|
+
const rawWrite = getBackendRawWriter(app.backend)
|
|
1391
|
+
if (rawWrite && await ensureOsc52Support()) {
|
|
1392
|
+
const sequence = buildOsc52ClipboardSequence(payload)
|
|
1393
|
+
if (sequence) {
|
|
1394
|
+
try {
|
|
1395
|
+
rawWrite(sequence)
|
|
1396
|
+
closeDropdown({ text: `Copied ${label} invite.`, variant: "success" })
|
|
1397
|
+
return
|
|
1398
|
+
} catch {}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
const opened = openExternalUrl(inviteCopyUrl(payload))
|
|
1402
|
+
closeDropdown(opened
|
|
1403
|
+
? { text: `Opened ${label} copy link.`, variant: "info" }
|
|
1404
|
+
: { text: `Unable to copy ${label} invite.`, variant: "error" })
|
|
1405
|
+
}
|
|
1236
1406
|
|
|
1237
1407
|
const flushUpdate = () => {
|
|
1238
1408
|
if (updateQueued || stopping || cleanedUp) return
|
|
@@ -1270,6 +1440,10 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1270
1440
|
...emptyFilePreviewState(),
|
|
1271
1441
|
...overrides,
|
|
1272
1442
|
})
|
|
1443
|
+
const exitDraftHistoryBrowse = () => commit(current =>
|
|
1444
|
+
current.draftHistory.index == null && current.draftHistory.baseInput == null
|
|
1445
|
+
? current
|
|
1446
|
+
: { ...current, draftHistory: resetDraftHistoryBrowse(current.draftHistory) })
|
|
1273
1447
|
|
|
1274
1448
|
const ensurePreviewSession = (workspaceRoot: string) => {
|
|
1275
1449
|
if (previewWorker && previewSessionId && previewSessionRoot === workspaceRoot) return
|
|
@@ -1365,17 +1539,21 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1365
1539
|
return scope
|
|
1366
1540
|
}
|
|
1367
1541
|
|
|
1368
|
-
const
|
|
1369
|
-
const
|
|
1370
|
-
|
|
1542
|
+
const applyDraftInputValue = (value: string, options: { cursor?: number; history?: DraftHistoryState } = {}) => {
|
|
1543
|
+
const nextValue = normalizeSearchQuery(value)
|
|
1544
|
+
if (options.cursor !== undefined) draftCursor = Math.min(options.cursor, nextValue.length)
|
|
1545
|
+
const scope = deriveFileSearchScope(nextValue, previewBaseRoot)
|
|
1546
|
+
const shouldDispose = !scope || state.filePreview.dismissedQuery === nextValue
|
|
1371
1547
|
commit(current => {
|
|
1372
|
-
|
|
1373
|
-
|
|
1548
|
+
const draftHistory = options.history ?? resetDraftHistoryBrowse(current.draftHistory)
|
|
1549
|
+
if (!scope) return { ...current, draftInput: nextValue, draftHistory, filePreview: resetFilePreview() }
|
|
1550
|
+
const shouldDismiss = current.filePreview.dismissedQuery === nextValue
|
|
1374
1551
|
const rootChanged = current.filePreview.workspaceRoot !== scope.workspaceRoot
|
|
1375
1552
|
const basePreview = rootChanged ? resetFilePreview() : current.filePreview
|
|
1376
1553
|
return {
|
|
1377
1554
|
...current,
|
|
1378
|
-
draftInput:
|
|
1555
|
+
draftInput: nextValue,
|
|
1556
|
+
draftHistory,
|
|
1379
1557
|
filePreview: {
|
|
1380
1558
|
...basePreview,
|
|
1381
1559
|
workspaceRoot: scope.workspaceRoot,
|
|
@@ -1391,7 +1569,19 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1391
1569
|
stopPreviewSession()
|
|
1392
1570
|
return
|
|
1393
1571
|
}
|
|
1394
|
-
requestFilePreview(
|
|
1572
|
+
requestFilePreview(nextValue)
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
const updateDraftInput = (value: string, cursor = draftCursor) => {
|
|
1576
|
+
applyDraftInputValue(value, { cursor })
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
const recallDraftHistory = (direction: -1 | 1) => {
|
|
1580
|
+
const next = moveDraftHistory(state.draftHistory, state.draftInput, direction)
|
|
1581
|
+
if (!next.changed) return false
|
|
1582
|
+
draftCursorBeforeEvent = 0
|
|
1583
|
+
applyDraftInputValue(next.value, { cursor: 0, history: next.history })
|
|
1584
|
+
return true
|
|
1395
1585
|
}
|
|
1396
1586
|
|
|
1397
1587
|
const acceptSelectedFilePreview = () => {
|
|
@@ -1413,6 +1603,8 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1413
1603
|
selectedIndex: null,
|
|
1414
1604
|
scrollTop: 0,
|
|
1415
1605
|
}, { text: `Browsing ${nextValue}`, variant: "info" }))
|
|
1606
|
+
draftCursor = nextValue.length
|
|
1607
|
+
draftCursorBeforeEvent = draftCursor
|
|
1416
1608
|
requestFilePreview(nextValue)
|
|
1417
1609
|
return true
|
|
1418
1610
|
}
|
|
@@ -1422,6 +1614,8 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1422
1614
|
resetFilePreview({ dismissedQuery: displayPath }),
|
|
1423
1615
|
{ text: `Selected ${displayPath}.`, variant: "success" },
|
|
1424
1616
|
))
|
|
1617
|
+
draftCursor = displayPath.length
|
|
1618
|
+
draftCursorBeforeEvent = draftCursor
|
|
1425
1619
|
stopPreviewSession()
|
|
1426
1620
|
return true
|
|
1427
1621
|
}
|
|
@@ -1473,10 +1667,12 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1473
1667
|
sessionSeed: nextSeed,
|
|
1474
1668
|
peerSelectionByRoom: current.peerSelectionByRoom,
|
|
1475
1669
|
snapshot: nextSession.snapshot(),
|
|
1670
|
+
inviteDropdownOpen: false,
|
|
1476
1671
|
peerSearch: "",
|
|
1477
1672
|
roomInput: nextSeed.room,
|
|
1478
1673
|
nameInput: visibleNameInput(nextSeed.name),
|
|
1479
1674
|
draftInput: "",
|
|
1675
|
+
draftHistory: resetDraftHistoryBrowse(current.draftHistory),
|
|
1480
1676
|
filePreview: resetFilePreview(),
|
|
1481
1677
|
drafts: [],
|
|
1482
1678
|
offeringDrafts: false,
|
|
@@ -1486,8 +1682,10 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1486
1682
|
pendingFocusTarget: current.pendingFocusTarget,
|
|
1487
1683
|
focusRequestEpoch: current.focusRequestEpoch,
|
|
1488
1684
|
bootNameJumpPending: current.bootNameJumpPending,
|
|
1489
|
-
|
|
1685
|
+
}),
|
|
1490
1686
|
}, { text, variant: "success" }))
|
|
1687
|
+
draftCursor = 0
|
|
1688
|
+
draftCursorBeforeEvent = 0
|
|
1491
1689
|
bindSession(nextSession)
|
|
1492
1690
|
void previousSession.close()
|
|
1493
1691
|
}
|
|
@@ -1499,6 +1697,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1499
1697
|
return
|
|
1500
1698
|
}
|
|
1501
1699
|
replaceSession({ ...state.sessionSeed, room: nextRoom }, `Joined room ${nextRoom}.`)
|
|
1700
|
+
draftCursor = 0
|
|
1502
1701
|
}
|
|
1503
1702
|
|
|
1504
1703
|
const commitName = () => {
|
|
@@ -1529,9 +1728,14 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1529
1728
|
commit(current => withNotice({
|
|
1530
1729
|
...current,
|
|
1531
1730
|
draftInput: current.draftInput === submittedInput ? "" : current.draftInput,
|
|
1731
|
+
draftHistory: pushDraftHistoryEntry(current.draftHistory, submittedInput),
|
|
1532
1732
|
filePreview: current.draftInput === submittedInput ? resetFilePreview() : current.filePreview,
|
|
1533
1733
|
drafts: [...current.drafts, created],
|
|
1534
1734
|
}, { text: `Added ${plural(1, "draft file")}.`, variant: "success" }))
|
|
1735
|
+
if (shouldDispose) {
|
|
1736
|
+
draftCursor = 0
|
|
1737
|
+
draftCursorBeforeEvent = 0
|
|
1738
|
+
}
|
|
1535
1739
|
if (shouldDispose) stopPreviewSession()
|
|
1536
1740
|
maybeOfferDrafts()
|
|
1537
1741
|
}, error => {
|
|
@@ -1541,8 +1745,12 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1541
1745
|
|
|
1542
1746
|
const actions: TuiActions = {
|
|
1543
1747
|
toggleEvents: () => commit(current => ({ ...withNotice(current, { text: current.eventsExpanded ? "Events hidden." : "Events shown.", variant: "info" }), eventsExpanded: !current.eventsExpanded })),
|
|
1544
|
-
openAbout: () => commit(current => ({ ...current, aboutOpen: true })),
|
|
1748
|
+
openAbout: () => commit(current => ({ ...current, aboutOpen: true, inviteDropdownOpen: false })),
|
|
1545
1749
|
closeAbout: () => commit(current => ({ ...current, aboutOpen: false })),
|
|
1750
|
+
toggleInviteDropdown: () => commit(current => ({ ...current, inviteDropdownOpen: !current.inviteDropdownOpen })),
|
|
1751
|
+
closeInviteDropdown: () => commit(current => current.inviteDropdownOpen ? { ...current, inviteDropdownOpen: false } : current),
|
|
1752
|
+
copyWebInvite: () => { void copyInvitePayload(webInviteUrl(state), "WEB") },
|
|
1753
|
+
copyCliInvite: () => { void copyInvitePayload(inviteCliText(state), "CLI") },
|
|
1546
1754
|
jumpToRandomRoom: () => replaceSession({ ...state.sessionSeed, room: uid(8) }, "Joined a new room."),
|
|
1547
1755
|
commitRoom,
|
|
1548
1756
|
setRoomInput: value => commit(current => ({ ...current, roomInput: value })),
|
|
@@ -1614,7 +1822,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1614
1822
|
error => commit(current => withNotice(current, { text: `${error}`, variant: "error" })),
|
|
1615
1823
|
)
|
|
1616
1824
|
},
|
|
1617
|
-
setDraftInput: value => updateDraftInput(value),
|
|
1825
|
+
setDraftInput: (value, cursor) => updateDraftInput(value, cursor),
|
|
1618
1826
|
addDrafts,
|
|
1619
1827
|
removeDraft: draftId => commit(current => withNotice({ ...current, drafts: current.drafts.filter(draft => draft.id !== draftId) }, { text: "Draft removed.", variant: "warning" })),
|
|
1620
1828
|
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" })),
|
|
@@ -1662,6 +1870,21 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1662
1870
|
}
|
|
1663
1871
|
|
|
1664
1872
|
app.view(model => renderTuiView(model, actions))
|
|
1873
|
+
app.onEvent((event: UiEvent) => {
|
|
1874
|
+
if (event.kind !== "engine" || state.focusedId !== DRAFT_INPUT_ID) return
|
|
1875
|
+
draftCursorBeforeEvent = draftCursor
|
|
1876
|
+
const edit = applyInputEditEvent(event.event, {
|
|
1877
|
+
id: DRAFT_INPUT_ID,
|
|
1878
|
+
value: state.draftInput,
|
|
1879
|
+
cursor: draftCursor,
|
|
1880
|
+
selectionStart: null,
|
|
1881
|
+
selectionEnd: null,
|
|
1882
|
+
multiline: false,
|
|
1883
|
+
})
|
|
1884
|
+
if (!edit) return
|
|
1885
|
+
draftCursor = edit.nextCursor
|
|
1886
|
+
if (!edit.action && state.draftHistory.index != null && draftCursor !== 0) exitDraftHistoryBrowse()
|
|
1887
|
+
})
|
|
1665
1888
|
app.onFocusChange(info => {
|
|
1666
1889
|
const previousFocusedId = state.focusedId
|
|
1667
1890
|
commit(current => {
|
|
@@ -1672,6 +1895,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1672
1895
|
stopPreviewSession()
|
|
1673
1896
|
commit(current => ({
|
|
1674
1897
|
...current,
|
|
1898
|
+
draftHistory: resetDraftHistoryBrowse(current.draftHistory),
|
|
1675
1899
|
filePreview: resetFilePreview({
|
|
1676
1900
|
dismissedQuery: current.filePreview.dismissedQuery === current.draftInput ? current.draftInput : null,
|
|
1677
1901
|
}),
|
|
@@ -1710,17 +1934,26 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1710
1934
|
acceptSelectedFilePreview()
|
|
1711
1935
|
},
|
|
1712
1936
|
},
|
|
1937
|
+
right: {
|
|
1938
|
+
description: "Accept focused preview row at end of Files input",
|
|
1939
|
+
when: ctx => canAcceptFilePreviewWithRight(state, draftCursorBeforeEvent) && ctx.focusedId === DRAFT_INPUT_ID,
|
|
1940
|
+
handler: () => {
|
|
1941
|
+
acceptSelectedFilePreview()
|
|
1942
|
+
},
|
|
1943
|
+
},
|
|
1713
1944
|
up: {
|
|
1714
|
-
description: "
|
|
1715
|
-
when: ctx => ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state) && state.filePreview.results.length > 0,
|
|
1945
|
+
description: "Recall history or move file preview selection up",
|
|
1946
|
+
when: ctx => ctx.focusedId === DRAFT_INPUT_ID && (canNavigateDraftHistory(state.draftHistory, state.draftInput, draftCursorBeforeEvent, previewBaseRoot) || filePreviewVisible(state) && state.filePreview.results.length > 0),
|
|
1716
1947
|
handler: () => {
|
|
1948
|
+
if (canNavigateDraftHistory(state.draftHistory, state.draftInput, draftCursorBeforeEvent, previewBaseRoot) && recallDraftHistory(-1)) return
|
|
1717
1949
|
commit(current => ({ ...current, filePreview: moveFilePreviewSelection(current.filePreview, -1) }))
|
|
1718
1950
|
},
|
|
1719
1951
|
},
|
|
1720
1952
|
down: {
|
|
1721
|
-
description: "
|
|
1722
|
-
when: ctx => ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state) && state.filePreview.results.length > 0,
|
|
1953
|
+
description: "Recall history or move file preview selection down",
|
|
1954
|
+
when: ctx => ctx.focusedId === DRAFT_INPUT_ID && (canNavigateDraftHistory(state.draftHistory, state.draftInput, draftCursorBeforeEvent, previewBaseRoot) || filePreviewVisible(state) && state.filePreview.results.length > 0),
|
|
1723
1955
|
handler: () => {
|
|
1956
|
+
if (canNavigateDraftHistory(state.draftHistory, state.draftInput, draftCursorBeforeEvent, previewBaseRoot) && recallDraftHistory(1)) return
|
|
1724
1957
|
commit(current => ({ ...current, filePreview: moveFilePreviewSelection(current.filePreview, 1) }))
|
|
1725
1958
|
},
|
|
1726
1959
|
},
|
|
@@ -1755,13 +1988,15 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1755
1988
|
if (ctx.focusedId === NAME_INPUT_ID) commit(current => withNotice({ ...current, nameInput: visibleNameInput(current.snapshot.name) }, { text: "Name input reset.", variant: "warning" }))
|
|
1756
1989
|
if (ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state)) {
|
|
1757
1990
|
stopPreviewSession()
|
|
1991
|
+
exitDraftHistoryBrowse()
|
|
1758
1992
|
commit(current => withNotice({
|
|
1759
1993
|
...current,
|
|
1760
1994
|
filePreview: resetFilePreview({ dismissedQuery: current.draftInput }),
|
|
1761
1995
|
}, { text: "File preview hidden.", variant: "warning" }))
|
|
1762
1996
|
} else if (ctx.focusedId === DRAFT_INPUT_ID) {
|
|
1763
1997
|
stopPreviewSession()
|
|
1764
|
-
|
|
1998
|
+
draftCursor = 0
|
|
1999
|
+
commit(current => withNotice({ ...current, draftInput: "", draftHistory: resetDraftHistoryBrowse(current.draftHistory), filePreview: resetFilePreview() }, { text: "Draft input cleared.", variant: "warning" }))
|
|
1765
2000
|
}
|
|
1766
2001
|
},
|
|
1767
2002
|
},
|
|
@@ -1787,6 +2022,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1787
2022
|
try {
|
|
1788
2023
|
bindSession(state.session)
|
|
1789
2024
|
await app.start()
|
|
2025
|
+
void ensureOsc52Support()
|
|
1790
2026
|
await quitController.promise
|
|
1791
2027
|
} finally {
|
|
1792
2028
|
try {
|
package/src/tui/file-search.ts
CHANGED
|
@@ -53,13 +53,16 @@ const BOUNDARY_CHARS = new Set(["/", "_", "-", "."])
|
|
|
53
53
|
export const FILE_SEARCH_SLOW_MOUNT_MS = 250
|
|
54
54
|
|
|
55
55
|
const trimTrailingCrLf = (value: string) => value.replace(/[\r\n]+$/u, "")
|
|
56
|
+
const trimMatchingQuotes = (value: string) => value.length >= 2 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))
|
|
57
|
+
? value.slice(1, -1)
|
|
58
|
+
: value
|
|
56
59
|
const normalizeSeparators = (value: string) => value.replace(/\\/gu, "/")
|
|
57
60
|
const pathChars = (value: string) => Array.from(value)
|
|
58
61
|
const lower = (value: string) => value.toLocaleLowerCase("en-US")
|
|
59
62
|
const renderedDisplayPrefix = (displayPrefix: string) => displayPrefix === "~" ? "~/" : displayPrefix
|
|
60
63
|
const MOUNTINFO_PATH = "/proc/self/mountinfo"
|
|
61
64
|
|
|
62
|
-
export const normalizeSearchQuery = (value: string) => normalizeSeparators(trimTrailingCrLf(value))
|
|
65
|
+
export const normalizeSearchQuery = (value: string) => normalizeSeparators(trimMatchingQuotes(trimTrailingCrLf(value)))
|
|
63
66
|
export const normalizeRelativePath = (value: string) => normalizeSeparators(value.split(sep).join("/"))
|
|
64
67
|
export const shouldSkipSearchDirectory = (name: string) => SKIPPED_DIRECTORIES.has(name)
|
|
65
68
|
export const isCaseSensitiveQuery = (query: string) => /[A-Z]/u.test(query)
|