@elefunc/send 0.1.14 → 0.1.15
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 +193 -27
- 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 { rgb, ui, 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
|
|
@@ -57,6 +65,7 @@ export interface TuiState {
|
|
|
57
65
|
bootNameJumpPending: boolean
|
|
58
66
|
draftInput: string
|
|
59
67
|
draftInputKeyVersion: number
|
|
68
|
+
draftHistory: DraftHistoryState
|
|
60
69
|
filePreview: FilePreviewState
|
|
61
70
|
drafts: DraftItem[]
|
|
62
71
|
autoOfferOutgoing: boolean
|
|
@@ -88,7 +97,7 @@ export interface TuiActions {
|
|
|
88
97
|
toggleAutoOffer: TuiAction
|
|
89
98
|
toggleAutoAccept: TuiAction
|
|
90
99
|
toggleAutoSave: TuiAction
|
|
91
|
-
setDraftInput: (value: string) => void
|
|
100
|
+
setDraftInput: (value: string, cursor?: number) => void
|
|
92
101
|
addDrafts: TuiAction
|
|
93
102
|
removeDraft: (draftId: string) => void
|
|
94
103
|
clearDrafts: TuiAction
|
|
@@ -111,6 +120,7 @@ const TRANSPARENT_BORDER_STYLE = { fg: rgb(7, 10, 12) } as const
|
|
|
111
120
|
const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
|
|
112
121
|
const PRIMARY_TEXT_STYLE = { fg: rgb(255, 255, 255) } as const
|
|
113
122
|
const HEADING_TEXT_STYLE = { fg: rgb(255, 255, 255), bold: true } as const
|
|
123
|
+
const MUTED_TEXT_STYLE = { fg: rgb(159, 166, 178) } as const
|
|
114
124
|
const DEFAULT_WEB_URL = "https://send.rt.ht/"
|
|
115
125
|
const DEFAULT_SAVE_DIR = resolve(process.cwd())
|
|
116
126
|
const ABOUT_ELEFUNC_URL = "https://elefunc.com/send"
|
|
@@ -129,6 +139,7 @@ const countFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0
|
|
|
129
139
|
const percentFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
|
|
130
140
|
const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
|
131
141
|
const pluralRules = new Intl.PluralRules()
|
|
142
|
+
const DRAFT_HISTORY_LIMIT = 50
|
|
132
143
|
|
|
133
144
|
export const visiblePanes = (showEvents: boolean): VisiblePane[] => showEvents ? ["peers", "transfers", "logs"] : ["peers", "transfers"]
|
|
134
145
|
|
|
@@ -380,7 +391,7 @@ const uaSummary = (profile?: PeerProfile) => joinSummary([profile?.ua?.browser,
|
|
|
380
391
|
const profileIp = (profile?: PeerProfile) => profile?.network?.ip || "—"
|
|
381
392
|
const peerDefaultsVariant = (profile?: PeerProfile): BadgeVariant => {
|
|
382
393
|
const token = peerDefaultsToken(profile)
|
|
383
|
-
return token === "
|
|
394
|
+
return token === "AX" ? "success" : token === "as" ? "warning" : token === "??" ? "default" : "info"
|
|
384
395
|
}
|
|
385
396
|
const TIGHT_TAG_COLORS = {
|
|
386
397
|
default: rgb(89, 194, 255),
|
|
@@ -389,6 +400,12 @@ const TIGHT_TAG_COLORS = {
|
|
|
389
400
|
error: rgb(240, 113, 120),
|
|
390
401
|
info: rgb(89, 194, 255),
|
|
391
402
|
} as const
|
|
403
|
+
const PREVIEW_PREFIX_STYLE = { fg: rgb(112, 121, 136), dim: true } as const
|
|
404
|
+
const PREVIEW_PATH_STYLE = { ...MUTED_TEXT_STYLE, dim: true } as const
|
|
405
|
+
const PREVIEW_BASENAME_STYLE = PRIMARY_TEXT_STYLE
|
|
406
|
+
const PREVIEW_HIGHLIGHT_STYLE = { fg: TIGHT_TAG_COLORS.info, bold: true } as const
|
|
407
|
+
const PREVIEW_SELECTED_HIGHLIGHT_STYLE = { fg: TIGHT_TAG_COLORS.success, bold: true } as const
|
|
408
|
+
const PREVIEW_SELECTED_MARKER_STYLE = { fg: TIGHT_TAG_COLORS.info, bold: true } as const
|
|
392
409
|
const tightTag = (text: string, props: { key?: string; variant?: BadgeVariant; bare?: boolean } = {}) => ui.text(props.bare ? text : `(${text})`, {
|
|
393
410
|
...(props.key === undefined ? {} : { key: props.key }),
|
|
394
411
|
style: {
|
|
@@ -408,6 +425,71 @@ const emptyFilePreviewState = (): FilePreviewState => ({
|
|
|
408
425
|
selectedIndex: null,
|
|
409
426
|
scrollTop: 0,
|
|
410
427
|
})
|
|
428
|
+
const emptyDraftHistoryState = (): DraftHistoryState => ({
|
|
429
|
+
entries: [],
|
|
430
|
+
index: null,
|
|
431
|
+
baseInput: null,
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
const resetDraftHistoryBrowse = (history: DraftHistoryState): DraftHistoryState =>
|
|
435
|
+
history.index == null && history.baseInput == null
|
|
436
|
+
? history
|
|
437
|
+
: { ...history, index: null, baseInput: null }
|
|
438
|
+
|
|
439
|
+
export const pushDraftHistoryEntry = (history: DraftHistoryState, value: string, limit = DRAFT_HISTORY_LIMIT): DraftHistoryState => {
|
|
440
|
+
const nextValue = normalizeSearchQuery(value)
|
|
441
|
+
if (!nextValue) return resetDraftHistoryBrowse(history)
|
|
442
|
+
return {
|
|
443
|
+
entries: history.entries[0] === nextValue ? history.entries : [nextValue, ...history.entries].slice(0, limit),
|
|
444
|
+
index: null,
|
|
445
|
+
baseInput: null,
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export const isDraftHistoryEntryPoint = (value: string, cursor: number, cwd = process.cwd()) => {
|
|
450
|
+
if (cursor !== 0) return false
|
|
451
|
+
const normalized = normalizeSearchQuery(value)
|
|
452
|
+
if (!normalized) return true
|
|
453
|
+
return !deriveFileSearchScope(value, cwd)?.query
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export const canNavigateDraftHistory = (history: DraftHistoryState, value: string, cursor: number, cwd = process.cwd()) =>
|
|
457
|
+
cursor === 0 && (history.index != null || (history.entries.length > 0 && isDraftHistoryEntryPoint(value, cursor, cwd)))
|
|
458
|
+
|
|
459
|
+
export const moveDraftHistory = (history: DraftHistoryState, value: string, direction: -1 | 1) => {
|
|
460
|
+
if (!history.entries.length) return { history, value, changed: false }
|
|
461
|
+
if (history.index == null) {
|
|
462
|
+
if (direction > 0) return { history, value, changed: false }
|
|
463
|
+
const nextIndex = 0
|
|
464
|
+
return {
|
|
465
|
+
history: { ...history, index: nextIndex, baseInput: value },
|
|
466
|
+
value: history.entries[nextIndex]!,
|
|
467
|
+
changed: history.entries[nextIndex] !== value,
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (direction < 0) {
|
|
471
|
+
const nextIndex = Math.min(history.entries.length - 1, history.index + 1)
|
|
472
|
+
return {
|
|
473
|
+
history: nextIndex === history.index ? history : { ...history, index: nextIndex },
|
|
474
|
+
value: history.entries[nextIndex]!,
|
|
475
|
+
changed: nextIndex !== history.index || history.entries[nextIndex] !== value,
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (history.index === 0) {
|
|
479
|
+
const nextValue = history.baseInput ?? ""
|
|
480
|
+
return {
|
|
481
|
+
history: resetDraftHistoryBrowse(history),
|
|
482
|
+
value: nextValue,
|
|
483
|
+
changed: nextValue !== value || history.index != null,
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const nextIndex = history.index - 1
|
|
487
|
+
return {
|
|
488
|
+
history: { ...history, index: nextIndex },
|
|
489
|
+
value: history.entries[nextIndex]!,
|
|
490
|
+
changed: history.entries[nextIndex] !== value,
|
|
491
|
+
}
|
|
492
|
+
}
|
|
411
493
|
|
|
412
494
|
type FocusControllerState = Pick<TuiState, "pendingFocusTarget" | "focusRequestEpoch" | "bootNameJumpPending">
|
|
413
495
|
|
|
@@ -493,6 +575,12 @@ export const transferActualDurationMs = (transfer: TransferSnapshot, now = Date.
|
|
|
493
575
|
export const transferWaitDurationMs = (transfer: TransferSnapshot, now = Date.now()) => Math.max(0, (transfer.startedAt || now) - transfer.createdAt)
|
|
494
576
|
export const filePreviewVisible = (state: Pick<TuiState, "focusedId" | "draftInput" | "filePreview">) =>
|
|
495
577
|
state.focusedId === DRAFT_INPUT_ID && !!normalizeSearchQuery(state.draftInput) && state.filePreview.dismissedQuery !== state.draftInput
|
|
578
|
+
export const canAcceptFilePreviewWithRight = (state: Pick<TuiState, "focusedId" | "draftInput" | "filePreview">, cursor: number) =>
|
|
579
|
+
state.focusedId === DRAFT_INPUT_ID
|
|
580
|
+
&& cursor === state.draftInput.length
|
|
581
|
+
&& filePreviewVisible(state)
|
|
582
|
+
&& state.filePreview.selectedIndex != null
|
|
583
|
+
&& !!state.filePreview.results[state.filePreview.selectedIndex]
|
|
496
584
|
export const clampFilePreviewSelectedIndex = (selectedIndex: number | null, resultCount: number) =>
|
|
497
585
|
!resultCount ? null : selectedIndex == null ? 0 : Math.max(0, Math.min(resultCount - 1, selectedIndex))
|
|
498
586
|
export const ensureFilePreviewScrollTop = (selectedIndex: number | null, scrollTop: number, resultCount: number, visibleRows = FILE_SEARCH_VISIBLE_ROWS) => {
|
|
@@ -519,24 +607,36 @@ const selectedFilePreviewMatch = (state: TuiState) => {
|
|
|
519
607
|
const index = state.filePreview.selectedIndex
|
|
520
608
|
return index == null ? null : state.filePreview.results[index] ?? null
|
|
521
609
|
}
|
|
522
|
-
const
|
|
610
|
+
export const previewPathSegments = (value: string, prefixLength: number, indices: number[]) => {
|
|
523
611
|
const marks = new Set(indices)
|
|
524
612
|
const chars = Array.from(value)
|
|
525
|
-
const
|
|
613
|
+
const basenameStart = Math.max(prefixLength, value.lastIndexOf("/") + 1)
|
|
614
|
+
const segments: PreviewSegment[] = []
|
|
526
615
|
let current = ""
|
|
527
616
|
let highlighted = false
|
|
617
|
+
let role: PreviewSegmentRole = "basename"
|
|
528
618
|
for (let index = 0; index < chars.length; index += 1) {
|
|
529
619
|
const nextHighlighted = marks.has(index)
|
|
530
|
-
|
|
531
|
-
|
|
620
|
+
const nextRole = index < prefixLength ? "prefix" : index < basenameStart ? "path" : "basename"
|
|
621
|
+
if (current && (nextHighlighted !== highlighted || nextRole !== role)) {
|
|
622
|
+
segments.push({ text: current, highlighted, role })
|
|
532
623
|
current = ""
|
|
533
624
|
}
|
|
534
625
|
current += chars[index]
|
|
535
626
|
highlighted = nextHighlighted
|
|
627
|
+
role = nextRole
|
|
536
628
|
}
|
|
537
|
-
if (current) segments.push({ text: current, highlighted })
|
|
629
|
+
if (current) segments.push({ text: current, highlighted, role })
|
|
538
630
|
return segments
|
|
539
631
|
}
|
|
632
|
+
export const previewSegmentStyle = (segment: PreviewSegment, selected: boolean): TextStyle =>
|
|
633
|
+
segment.highlighted
|
|
634
|
+
? selected ? PREVIEW_SELECTED_HIGHLIGHT_STYLE : PREVIEW_HIGHLIGHT_STYLE
|
|
635
|
+
: segment.role === "prefix"
|
|
636
|
+
? PREVIEW_PREFIX_STYLE
|
|
637
|
+
: segment.role === "path"
|
|
638
|
+
? PREVIEW_PATH_STYLE
|
|
639
|
+
: PREVIEW_BASENAME_STYLE
|
|
540
640
|
|
|
541
641
|
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
642
|
const order = ["draft", "pending", "queued", "offered", "accepted", "receiving", "sending", "awaiting-done", "cancelling", "complete", "rejected", "cancelled", "error"]
|
|
@@ -609,6 +709,7 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
|
|
|
609
709
|
...focusState,
|
|
610
710
|
draftInput: "",
|
|
611
711
|
draftInputKeyVersion: 0,
|
|
712
|
+
draftHistory: emptyDraftHistoryState(),
|
|
612
713
|
filePreview: emptyFilePreviewState(),
|
|
613
714
|
drafts: [],
|
|
614
715
|
autoOfferOutgoing: launchOptions.offer ?? true,
|
|
@@ -939,14 +1040,14 @@ const renderDraftSummary = (drafts: DraftItem[]) => denseSection({
|
|
|
939
1040
|
ui.row({ gap: 1, wrap: true }, summarizeStates(drafts, () => "draft", draft => draft.size, ["draft"]).map(renderSummaryStat)),
|
|
940
1041
|
])
|
|
941
1042
|
|
|
942
|
-
const renderHighlightedPreviewPath = (value: string, indices: number[], options: { id?: string; key?: string; flex?: number } = {}) => ui.row({
|
|
1043
|
+
const renderHighlightedPreviewPath = (value: string, prefixLength: number, indices: number[], selected: boolean, options: { id?: string; key?: string; flex?: number } = {}) => ui.row({
|
|
943
1044
|
gap: 0,
|
|
944
1045
|
wrap: true,
|
|
945
1046
|
...(options.id === undefined ? {} : { id: options.id }),
|
|
946
1047
|
...(options.key === undefined ? {} : { key: options.key }),
|
|
947
1048
|
...(options.flex === undefined ? {} : { flex: options.flex }),
|
|
948
|
-
},
|
|
949
|
-
ui.text(segment.text, { key: `segment-${index}`,
|
|
1049
|
+
}, previewPathSegments(value, prefixLength, indices).map((segment, index) =>
|
|
1050
|
+
ui.text(segment.text, { key: `segment-${index}`, style: previewSegmentStyle(segment, selected) }),
|
|
950
1051
|
))
|
|
951
1052
|
|
|
952
1053
|
const renderFilePreviewRow = (match: FileSearchMatch, index: number, selected: boolean, displayPrefix: string) => ui.row({
|
|
@@ -955,10 +1056,12 @@ const renderFilePreviewRow = (match: FileSearchMatch, index: number, selected: b
|
|
|
955
1056
|
gap: 1,
|
|
956
1057
|
wrap: true,
|
|
957
1058
|
}, [
|
|
958
|
-
ui.text(selected ? ">" : " "),
|
|
1059
|
+
ui.text(selected ? ">" : " ", { style: selected ? PREVIEW_SELECTED_MARKER_STYLE : PREVIEW_PATH_STYLE }),
|
|
959
1060
|
renderHighlightedPreviewPath(
|
|
960
1061
|
formatFileSearchDisplayPath(displayPrefix, match.relativePath),
|
|
1062
|
+
Array.from(formatFileSearchDisplayPath(displayPrefix, "")).length,
|
|
961
1063
|
offsetFileSearchMatchIndices(displayPrefix, match.indices),
|
|
1064
|
+
selected,
|
|
962
1065
|
{ id: `file-preview-path-${index}`, flex: 1 },
|
|
963
1066
|
),
|
|
964
1067
|
match.kind === "file" && typeof match.size === "number" ? ui.text(formatBytes(match.size), { style: { dim: true } }) : null,
|
|
@@ -1108,7 +1211,7 @@ const renderFilesCard = (state: TuiState, actions: TuiActions) => denseSection({
|
|
|
1108
1211
|
key: `draft-input-${state.draftInputKeyVersion}`,
|
|
1109
1212
|
value: state.draftInput,
|
|
1110
1213
|
placeholder: "path/to/file.txt",
|
|
1111
|
-
onInput: value => actions.setDraftInput(value),
|
|
1214
|
+
onInput: (value, cursor) => actions.setDraftInput(value, cursor),
|
|
1112
1215
|
}),
|
|
1113
1216
|
]),
|
|
1114
1217
|
actionButton("add-drafts", "Add", actions.addDrafts, "primary", !state.draftInput.trim()),
|
|
@@ -1214,6 +1317,7 @@ export const withAcceptedDraftInput = (state: TuiState, draftInput: string, file
|
|
|
1214
1317
|
...state,
|
|
1215
1318
|
draftInput,
|
|
1216
1319
|
draftInputKeyVersion: state.draftInputKeyVersion + 1,
|
|
1320
|
+
draftHistory: resetDraftHistoryBrowse(state.draftHistory),
|
|
1217
1321
|
filePreview,
|
|
1218
1322
|
pendingFocusTarget: DRAFT_INPUT_ID,
|
|
1219
1323
|
focusRequestEpoch: state.focusRequestEpoch + 1,
|
|
@@ -1233,6 +1337,8 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1233
1337
|
let previewWorker: Worker | null = null
|
|
1234
1338
|
let previewSessionId: string | null = null
|
|
1235
1339
|
let previewSessionRoot: string | null = null
|
|
1340
|
+
let draftCursor = state.draftInput.length
|
|
1341
|
+
let draftCursorBeforeEvent = draftCursor
|
|
1236
1342
|
|
|
1237
1343
|
const flushUpdate = () => {
|
|
1238
1344
|
if (updateQueued || stopping || cleanedUp) return
|
|
@@ -1270,6 +1376,10 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1270
1376
|
...emptyFilePreviewState(),
|
|
1271
1377
|
...overrides,
|
|
1272
1378
|
})
|
|
1379
|
+
const exitDraftHistoryBrowse = () => commit(current =>
|
|
1380
|
+
current.draftHistory.index == null && current.draftHistory.baseInput == null
|
|
1381
|
+
? current
|
|
1382
|
+
: { ...current, draftHistory: resetDraftHistoryBrowse(current.draftHistory) })
|
|
1273
1383
|
|
|
1274
1384
|
const ensurePreviewSession = (workspaceRoot: string) => {
|
|
1275
1385
|
if (previewWorker && previewSessionId && previewSessionRoot === workspaceRoot) return
|
|
@@ -1365,17 +1475,21 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1365
1475
|
return scope
|
|
1366
1476
|
}
|
|
1367
1477
|
|
|
1368
|
-
const
|
|
1369
|
-
const
|
|
1370
|
-
|
|
1478
|
+
const applyDraftInputValue = (value: string, options: { cursor?: number; history?: DraftHistoryState } = {}) => {
|
|
1479
|
+
const nextValue = normalizeSearchQuery(value)
|
|
1480
|
+
if (options.cursor !== undefined) draftCursor = Math.min(options.cursor, nextValue.length)
|
|
1481
|
+
const scope = deriveFileSearchScope(nextValue, previewBaseRoot)
|
|
1482
|
+
const shouldDispose = !scope || state.filePreview.dismissedQuery === nextValue
|
|
1371
1483
|
commit(current => {
|
|
1372
|
-
|
|
1373
|
-
|
|
1484
|
+
const draftHistory = options.history ?? resetDraftHistoryBrowse(current.draftHistory)
|
|
1485
|
+
if (!scope) return { ...current, draftInput: nextValue, draftHistory, filePreview: resetFilePreview() }
|
|
1486
|
+
const shouldDismiss = current.filePreview.dismissedQuery === nextValue
|
|
1374
1487
|
const rootChanged = current.filePreview.workspaceRoot !== scope.workspaceRoot
|
|
1375
1488
|
const basePreview = rootChanged ? resetFilePreview() : current.filePreview
|
|
1376
1489
|
return {
|
|
1377
1490
|
...current,
|
|
1378
|
-
draftInput:
|
|
1491
|
+
draftInput: nextValue,
|
|
1492
|
+
draftHistory,
|
|
1379
1493
|
filePreview: {
|
|
1380
1494
|
...basePreview,
|
|
1381
1495
|
workspaceRoot: scope.workspaceRoot,
|
|
@@ -1391,7 +1505,19 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1391
1505
|
stopPreviewSession()
|
|
1392
1506
|
return
|
|
1393
1507
|
}
|
|
1394
|
-
requestFilePreview(
|
|
1508
|
+
requestFilePreview(nextValue)
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
const updateDraftInput = (value: string, cursor = draftCursor) => {
|
|
1512
|
+
applyDraftInputValue(value, { cursor })
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const recallDraftHistory = (direction: -1 | 1) => {
|
|
1516
|
+
const next = moveDraftHistory(state.draftHistory, state.draftInput, direction)
|
|
1517
|
+
if (!next.changed) return false
|
|
1518
|
+
draftCursorBeforeEvent = 0
|
|
1519
|
+
applyDraftInputValue(next.value, { cursor: 0, history: next.history })
|
|
1520
|
+
return true
|
|
1395
1521
|
}
|
|
1396
1522
|
|
|
1397
1523
|
const acceptSelectedFilePreview = () => {
|
|
@@ -1413,6 +1539,8 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1413
1539
|
selectedIndex: null,
|
|
1414
1540
|
scrollTop: 0,
|
|
1415
1541
|
}, { text: `Browsing ${nextValue}`, variant: "info" }))
|
|
1542
|
+
draftCursor = nextValue.length
|
|
1543
|
+
draftCursorBeforeEvent = draftCursor
|
|
1416
1544
|
requestFilePreview(nextValue)
|
|
1417
1545
|
return true
|
|
1418
1546
|
}
|
|
@@ -1422,6 +1550,8 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1422
1550
|
resetFilePreview({ dismissedQuery: displayPath }),
|
|
1423
1551
|
{ text: `Selected ${displayPath}.`, variant: "success" },
|
|
1424
1552
|
))
|
|
1553
|
+
draftCursor = displayPath.length
|
|
1554
|
+
draftCursorBeforeEvent = draftCursor
|
|
1425
1555
|
stopPreviewSession()
|
|
1426
1556
|
return true
|
|
1427
1557
|
}
|
|
@@ -1477,6 +1607,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1477
1607
|
roomInput: nextSeed.room,
|
|
1478
1608
|
nameInput: visibleNameInput(nextSeed.name),
|
|
1479
1609
|
draftInput: "",
|
|
1610
|
+
draftHistory: resetDraftHistoryBrowse(current.draftHistory),
|
|
1480
1611
|
filePreview: resetFilePreview(),
|
|
1481
1612
|
drafts: [],
|
|
1482
1613
|
offeringDrafts: false,
|
|
@@ -1486,8 +1617,10 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1486
1617
|
pendingFocusTarget: current.pendingFocusTarget,
|
|
1487
1618
|
focusRequestEpoch: current.focusRequestEpoch,
|
|
1488
1619
|
bootNameJumpPending: current.bootNameJumpPending,
|
|
1489
|
-
|
|
1620
|
+
}),
|
|
1490
1621
|
}, { text, variant: "success" }))
|
|
1622
|
+
draftCursor = 0
|
|
1623
|
+
draftCursorBeforeEvent = 0
|
|
1491
1624
|
bindSession(nextSession)
|
|
1492
1625
|
void previousSession.close()
|
|
1493
1626
|
}
|
|
@@ -1499,6 +1632,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1499
1632
|
return
|
|
1500
1633
|
}
|
|
1501
1634
|
replaceSession({ ...state.sessionSeed, room: nextRoom }, `Joined room ${nextRoom}.`)
|
|
1635
|
+
draftCursor = 0
|
|
1502
1636
|
}
|
|
1503
1637
|
|
|
1504
1638
|
const commitName = () => {
|
|
@@ -1529,9 +1663,14 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1529
1663
|
commit(current => withNotice({
|
|
1530
1664
|
...current,
|
|
1531
1665
|
draftInput: current.draftInput === submittedInput ? "" : current.draftInput,
|
|
1666
|
+
draftHistory: pushDraftHistoryEntry(current.draftHistory, submittedInput),
|
|
1532
1667
|
filePreview: current.draftInput === submittedInput ? resetFilePreview() : current.filePreview,
|
|
1533
1668
|
drafts: [...current.drafts, created],
|
|
1534
1669
|
}, { text: `Added ${plural(1, "draft file")}.`, variant: "success" }))
|
|
1670
|
+
if (shouldDispose) {
|
|
1671
|
+
draftCursor = 0
|
|
1672
|
+
draftCursorBeforeEvent = 0
|
|
1673
|
+
}
|
|
1535
1674
|
if (shouldDispose) stopPreviewSession()
|
|
1536
1675
|
maybeOfferDrafts()
|
|
1537
1676
|
}, error => {
|
|
@@ -1614,7 +1753,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1614
1753
|
error => commit(current => withNotice(current, { text: `${error}`, variant: "error" })),
|
|
1615
1754
|
)
|
|
1616
1755
|
},
|
|
1617
|
-
setDraftInput: value => updateDraftInput(value),
|
|
1756
|
+
setDraftInput: (value, cursor) => updateDraftInput(value, cursor),
|
|
1618
1757
|
addDrafts,
|
|
1619
1758
|
removeDraft: draftId => commit(current => withNotice({ ...current, drafts: current.drafts.filter(draft => draft.id !== draftId) }, { text: "Draft removed.", variant: "warning" })),
|
|
1620
1759
|
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 +1801,21 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1662
1801
|
}
|
|
1663
1802
|
|
|
1664
1803
|
app.view(model => renderTuiView(model, actions))
|
|
1804
|
+
app.onEvent((event: UiEvent) => {
|
|
1805
|
+
if (event.kind !== "engine" || state.focusedId !== DRAFT_INPUT_ID) return
|
|
1806
|
+
draftCursorBeforeEvent = draftCursor
|
|
1807
|
+
const edit = applyInputEditEvent(event.event, {
|
|
1808
|
+
id: DRAFT_INPUT_ID,
|
|
1809
|
+
value: state.draftInput,
|
|
1810
|
+
cursor: draftCursor,
|
|
1811
|
+
selectionStart: null,
|
|
1812
|
+
selectionEnd: null,
|
|
1813
|
+
multiline: false,
|
|
1814
|
+
})
|
|
1815
|
+
if (!edit) return
|
|
1816
|
+
draftCursor = edit.nextCursor
|
|
1817
|
+
if (!edit.action && state.draftHistory.index != null && draftCursor !== 0) exitDraftHistoryBrowse()
|
|
1818
|
+
})
|
|
1665
1819
|
app.onFocusChange(info => {
|
|
1666
1820
|
const previousFocusedId = state.focusedId
|
|
1667
1821
|
commit(current => {
|
|
@@ -1672,6 +1826,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1672
1826
|
stopPreviewSession()
|
|
1673
1827
|
commit(current => ({
|
|
1674
1828
|
...current,
|
|
1829
|
+
draftHistory: resetDraftHistoryBrowse(current.draftHistory),
|
|
1675
1830
|
filePreview: resetFilePreview({
|
|
1676
1831
|
dismissedQuery: current.filePreview.dismissedQuery === current.draftInput ? current.draftInput : null,
|
|
1677
1832
|
}),
|
|
@@ -1710,17 +1865,26 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1710
1865
|
acceptSelectedFilePreview()
|
|
1711
1866
|
},
|
|
1712
1867
|
},
|
|
1868
|
+
right: {
|
|
1869
|
+
description: "Accept focused preview row at end of Files input",
|
|
1870
|
+
when: ctx => canAcceptFilePreviewWithRight(state, draftCursorBeforeEvent) && ctx.focusedId === DRAFT_INPUT_ID,
|
|
1871
|
+
handler: () => {
|
|
1872
|
+
acceptSelectedFilePreview()
|
|
1873
|
+
},
|
|
1874
|
+
},
|
|
1713
1875
|
up: {
|
|
1714
|
-
description: "
|
|
1715
|
-
when: ctx => ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state) && state.filePreview.results.length > 0,
|
|
1876
|
+
description: "Recall history or move file preview selection up",
|
|
1877
|
+
when: ctx => ctx.focusedId === DRAFT_INPUT_ID && (canNavigateDraftHistory(state.draftHistory, state.draftInput, draftCursorBeforeEvent, previewBaseRoot) || filePreviewVisible(state) && state.filePreview.results.length > 0),
|
|
1716
1878
|
handler: () => {
|
|
1879
|
+
if (canNavigateDraftHistory(state.draftHistory, state.draftInput, draftCursorBeforeEvent, previewBaseRoot) && recallDraftHistory(-1)) return
|
|
1717
1880
|
commit(current => ({ ...current, filePreview: moveFilePreviewSelection(current.filePreview, -1) }))
|
|
1718
1881
|
},
|
|
1719
1882
|
},
|
|
1720
1883
|
down: {
|
|
1721
|
-
description: "
|
|
1722
|
-
when: ctx => ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state) && state.filePreview.results.length > 0,
|
|
1884
|
+
description: "Recall history or move file preview selection down",
|
|
1885
|
+
when: ctx => ctx.focusedId === DRAFT_INPUT_ID && (canNavigateDraftHistory(state.draftHistory, state.draftInput, draftCursorBeforeEvent, previewBaseRoot) || filePreviewVisible(state) && state.filePreview.results.length > 0),
|
|
1723
1886
|
handler: () => {
|
|
1887
|
+
if (canNavigateDraftHistory(state.draftHistory, state.draftInput, draftCursorBeforeEvent, previewBaseRoot) && recallDraftHistory(1)) return
|
|
1724
1888
|
commit(current => ({ ...current, filePreview: moveFilePreviewSelection(current.filePreview, 1) }))
|
|
1725
1889
|
},
|
|
1726
1890
|
},
|
|
@@ -1755,13 +1919,15 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
|
|
|
1755
1919
|
if (ctx.focusedId === NAME_INPUT_ID) commit(current => withNotice({ ...current, nameInput: visibleNameInput(current.snapshot.name) }, { text: "Name input reset.", variant: "warning" }))
|
|
1756
1920
|
if (ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state)) {
|
|
1757
1921
|
stopPreviewSession()
|
|
1922
|
+
exitDraftHistoryBrowse()
|
|
1758
1923
|
commit(current => withNotice({
|
|
1759
1924
|
...current,
|
|
1760
1925
|
filePreview: resetFilePreview({ dismissedQuery: current.draftInput }),
|
|
1761
1926
|
}, { text: "File preview hidden.", variant: "warning" }))
|
|
1762
1927
|
} else if (ctx.focusedId === DRAFT_INPUT_ID) {
|
|
1763
1928
|
stopPreviewSession()
|
|
1764
|
-
|
|
1929
|
+
draftCursor = 0
|
|
1930
|
+
commit(current => withNotice({ ...current, draftInput: "", draftHistory: resetDraftHistoryBrowse(current.draftHistory), filePreview: resetFilePreview() }, { text: "Draft input cleared.", variant: "warning" }))
|
|
1765
1931
|
}
|
|
1766
1932
|
},
|
|
1767
1933
|
},
|
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)
|