@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elefunc/send",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Browser-compatible file transfer CLI and TUI powered by Bun, WebRTC, and Rezi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- return autoAcceptIncoming === null || autoSaveIncoming === null ? "??" : `${autoAcceptIncoming ? "A" : "a"}${autoSaveIncoming ? "S" : "s"}`
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}`
@@ -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 === "AS" ? "success" : token === "as" ? "warning" : token === "??" ? "default" : "info"
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 highlightedSegments = (value: string, indices: number[]) => {
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 segments: Array<{ text: string; highlighted: boolean }> = []
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
- if (current && nextHighlighted !== highlighted) {
531
- segments.push({ text: current, highlighted })
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
- }, highlightedSegments(value, indices).map((segment, index) =>
949
- ui.text(segment.text, { key: `segment-${index}`, ...(segment.highlighted ? { style: { bold: true } } : {}) }),
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 updateDraftInput = (value: string) => {
1369
- const scope = deriveFileSearchScope(value, previewBaseRoot)
1370
- const shouldDispose = !scope || state.filePreview.dismissedQuery === value
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
- if (!scope) return { ...current, draftInput: value, filePreview: resetFilePreview() }
1373
- const shouldDismiss = current.filePreview.dismissedQuery === value
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: value,
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(value)
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: "Move file preview selection up",
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: "Move file preview selection down",
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
- commit(current => withNotice({ ...current, draftInput: "", filePreview: resetFilePreview() }, { text: "Draft input cleared.", variant: "warning" }))
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
  },
@@ -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)