@elefunc/send 0.1.13 → 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/files.ts +44 -11
- package/src/core/protocol.ts +4 -1
- package/src/core/session.ts +178 -10
- package/src/index.ts +3 -2
- package/src/tui/app.ts +193 -27
- package/src/tui/file-search.ts +4 -1
package/package.json
CHANGED
package/src/core/files.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { access, mkdir, stat } from "node:fs/promises"
|
|
1
|
+
import { access, mkdir, open, rm, stat, type FileHandle } from "node:fs/promises"
|
|
2
2
|
import { basename, extname, join, resolve } from "node:path"
|
|
3
3
|
import { resolveUserPath } from "./paths"
|
|
4
4
|
|
|
@@ -8,16 +8,16 @@ export interface LocalFile {
|
|
|
8
8
|
size: number
|
|
9
9
|
type: string
|
|
10
10
|
lastModified: number
|
|
11
|
-
|
|
11
|
+
reader?: FileHandle
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export type LocalFileInfo = Omit<LocalFile, "
|
|
14
|
+
export type LocalFileInfo = Omit<LocalFile, "reader">
|
|
15
15
|
export interface LocalPathIssue {
|
|
16
16
|
path: string
|
|
17
17
|
error: string
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
const
|
|
20
|
+
export const pathExists = async (path: string) => access(path).then(() => true, () => false)
|
|
21
21
|
|
|
22
22
|
export const inspectLocalFile = async (path: string): Promise<LocalFileInfo> => {
|
|
23
23
|
const absolute = resolveUserPath(path)
|
|
@@ -52,23 +52,52 @@ export const inspectLocalPaths = async (paths: string[]) => {
|
|
|
52
52
|
|
|
53
53
|
export const loadLocalFile = async (path: string): Promise<LocalFile> => {
|
|
54
54
|
const info = await inspectLocalFile(path)
|
|
55
|
-
return {
|
|
56
|
-
...info,
|
|
57
|
-
blob: Bun.file(info.path),
|
|
58
|
-
}
|
|
55
|
+
return { ...info }
|
|
59
56
|
}
|
|
60
57
|
|
|
61
58
|
export const loadLocalFiles = (paths: string[]) => Promise.all(paths.map(loadLocalFile))
|
|
62
59
|
|
|
63
|
-
|
|
60
|
+
const readHandleChunk = async (handle: FileHandle, offset: number, size: number) => {
|
|
61
|
+
const chunk = Buffer.allocUnsafe(size)
|
|
62
|
+
let bytesReadTotal = 0
|
|
63
|
+
while (bytesReadTotal < size) {
|
|
64
|
+
const { bytesRead } = await handle.read(chunk, bytesReadTotal, size - bytesReadTotal, offset + bytesReadTotal)
|
|
65
|
+
if (!bytesRead) break
|
|
66
|
+
bytesReadTotal += bytesRead
|
|
67
|
+
}
|
|
68
|
+
return bytesReadTotal === size ? chunk : chunk.subarray(0, bytesReadTotal)
|
|
69
|
+
}
|
|
64
70
|
|
|
65
|
-
export const
|
|
71
|
+
export const readFileChunk = async (file: LocalFile, offset: number, size: number) => {
|
|
72
|
+
file.reader ||= await open(file.path, "r")
|
|
73
|
+
return readHandleChunk(file.reader, offset, size)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const closeLocalFile = async (file?: LocalFile) => {
|
|
77
|
+
if (!file?.reader) return
|
|
78
|
+
const reader = file.reader
|
|
79
|
+
file.reader = undefined
|
|
80
|
+
await reader.close()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const writeFileChunk = async (handle: FileHandle, data: Buffer, offset: number) => {
|
|
84
|
+
let bytesWrittenTotal = 0
|
|
85
|
+
while (bytesWrittenTotal < data.byteLength) {
|
|
86
|
+
const { bytesWritten } = await handle.write(data, bytesWrittenTotal, data.byteLength - bytesWrittenTotal, offset + bytesWrittenTotal)
|
|
87
|
+
if (!bytesWritten) throw new Error("short write")
|
|
88
|
+
bytesWrittenTotal += bytesWritten
|
|
89
|
+
}
|
|
90
|
+
return bytesWrittenTotal
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const uniqueOutputPath = async (directory: string, fileName: string, reservedPaths: ReadonlySet<string> = new Set()) => {
|
|
66
94
|
await mkdir(directory, { recursive: true })
|
|
67
95
|
const extension = extname(fileName)
|
|
68
96
|
const stem = extension ? fileName.slice(0, -extension.length) : fileName
|
|
69
97
|
for (let index = 0; ; index += 1) {
|
|
70
98
|
const candidate = join(directory, index ? `${stem} (${index})${extension}` : fileName)
|
|
71
|
-
if (
|
|
99
|
+
if (reservedPaths.has(candidate)) continue
|
|
100
|
+
if (!await pathExists(candidate)) return candidate
|
|
72
101
|
}
|
|
73
102
|
}
|
|
74
103
|
|
|
@@ -77,3 +106,7 @@ export const saveIncomingFile = async (directory: string, fileName: string, data
|
|
|
77
106
|
await Bun.write(path, data)
|
|
78
107
|
return path
|
|
79
108
|
}
|
|
109
|
+
|
|
110
|
+
export const removePath = async (path: string) => {
|
|
111
|
+
await rm(path, { force: true }).catch(() => {})
|
|
112
|
+
}
|
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
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { open, rename, type FileHandle } from "node:fs/promises"
|
|
1
2
|
import { resolve } from "node:path"
|
|
2
3
|
import type { RTCDataChannel, RTCIceCandidateInit, RTCIceServer } from "werift"
|
|
3
4
|
import { RTCPeerConnection } from "werift"
|
|
4
|
-
import { loadLocalFiles, readFileChunk, saveIncomingFile, type LocalFile } from "./files"
|
|
5
|
+
import { closeLocalFile, loadLocalFiles, pathExists, readFileChunk, removePath, saveIncomingFile, uniqueOutputPath, writeFileChunk, type LocalFile } from "./files"
|
|
5
6
|
import {
|
|
6
7
|
BASE_ICE_SERVERS,
|
|
7
8
|
BUFFER_HIGH,
|
|
@@ -87,12 +88,23 @@ interface TransferState {
|
|
|
87
88
|
file?: LocalFile
|
|
88
89
|
buffers?: Buffer[]
|
|
89
90
|
data?: Buffer
|
|
91
|
+
incomingDisk?: IncomingDiskState
|
|
90
92
|
inFlight: boolean
|
|
91
93
|
cancel: boolean
|
|
92
94
|
cancelReason?: string
|
|
93
95
|
cancelSource?: "local" | "remote"
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
interface IncomingDiskState {
|
|
99
|
+
finalPath: string
|
|
100
|
+
tempPath: string
|
|
101
|
+
handle: FileHandle
|
|
102
|
+
queue: Promise<void>
|
|
103
|
+
offset: number
|
|
104
|
+
error: string
|
|
105
|
+
closed: boolean
|
|
106
|
+
}
|
|
107
|
+
|
|
96
108
|
export interface PeerSnapshot {
|
|
97
109
|
id: string
|
|
98
110
|
name: string
|
|
@@ -218,6 +230,7 @@ export const sanitizeProfile = (profile?: PeerProfile): PeerProfile => ({
|
|
|
218
230
|
autoAcceptIncoming: typeof profile?.defaults?.autoAcceptIncoming === "boolean" ? profile.defaults.autoAcceptIncoming : undefined,
|
|
219
231
|
autoSaveIncoming: typeof profile?.defaults?.autoSaveIncoming === "boolean" ? profile.defaults.autoSaveIncoming : undefined,
|
|
220
232
|
},
|
|
233
|
+
streamingSaveIncoming: typeof profile?.streamingSaveIncoming === "boolean" ? profile.streamingSaveIncoming : undefined,
|
|
221
234
|
ready: !!profile?.ready,
|
|
222
235
|
error: cleanText(profile?.error, 120),
|
|
223
236
|
})
|
|
@@ -242,6 +255,7 @@ export const localProfileFromResponse = (data: unknown, error = ""): PeerProfile
|
|
|
242
255
|
ip: cleaned(value?.hs?.["cf-connecting-ip"] || value?.hs?.["x-real-ip"], 80),
|
|
243
256
|
},
|
|
244
257
|
ua: buildCliProfile().ua,
|
|
258
|
+
streamingSaveIncoming: true,
|
|
245
259
|
ready: !!value?.cf,
|
|
246
260
|
error,
|
|
247
261
|
})
|
|
@@ -384,6 +398,7 @@ export class SendSession {
|
|
|
384
398
|
private readonly logs: LogEntry[] = []
|
|
385
399
|
private readonly subscribers = new Set<() => void>()
|
|
386
400
|
private readonly eventSubscribers = new Set<(event: SessionEvent) => void>()
|
|
401
|
+
private readonly reservedSavePaths = new Set<string>()
|
|
387
402
|
|
|
388
403
|
private rtcEpochCounter = 0
|
|
389
404
|
private socket: WebSocket | null = null
|
|
@@ -674,7 +689,14 @@ export class SendSession {
|
|
|
674
689
|
if (!transfer || transfer.direction !== "in" || isFinal(transfer)) return false
|
|
675
690
|
const peer = this.peers.get(transfer.peerId)
|
|
676
691
|
if (!peer || !this.isPeerReady(peer)) return false
|
|
677
|
-
|
|
692
|
+
const streamingToDisk = await this.startIncomingDiskTransfer(transfer)
|
|
693
|
+
transfer.data = undefined
|
|
694
|
+
transfer.buffers = streamingToDisk ? undefined : []
|
|
695
|
+
if (!this.sendDataControl(peer, { kind: "file-accept", transferId })) {
|
|
696
|
+
if (streamingToDisk) this.discardIncomingDiskTransfer(transfer)
|
|
697
|
+
transfer.buffers = []
|
|
698
|
+
return false
|
|
699
|
+
}
|
|
678
700
|
transfer.status = "accepted"
|
|
679
701
|
this.noteTransfer(transfer)
|
|
680
702
|
this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
|
|
@@ -738,13 +760,12 @@ export class SendSession {
|
|
|
738
760
|
async saveTransfer(transferId: string) {
|
|
739
761
|
const transfer = this.transfers.get(transferId)
|
|
740
762
|
if (!transfer || transfer.direction !== "in" || transfer.status !== "complete") return null
|
|
763
|
+
if (transfer.savedPath) return transfer.savedPath
|
|
741
764
|
if (!transfer.data && transfer.buffers?.length) transfer.data = Buffer.concat(transfer.buffers)
|
|
742
765
|
if (!transfer.data) return null
|
|
743
|
-
transfer.savedPath
|
|
766
|
+
transfer.savedPath = await saveIncomingFile(this.saveDir, transfer.name, transfer.data)
|
|
744
767
|
transfer.savedAt ||= Date.now()
|
|
745
|
-
|
|
746
|
-
this.pushLog("transfer:saved", { transferId: transfer.id, path: transfer.savedPath })
|
|
747
|
-
this.emit({ type: "saved", transfer: snapshot })
|
|
768
|
+
this.emitTransferSaved(transfer)
|
|
748
769
|
this.notify()
|
|
749
770
|
return transfer.savedPath
|
|
750
771
|
}
|
|
@@ -753,6 +774,124 @@ export class SendSession {
|
|
|
753
774
|
return this.transfers.get(transferId)
|
|
754
775
|
}
|
|
755
776
|
|
|
777
|
+
private emitTransferSaved(transfer: TransferState) {
|
|
778
|
+
const snapshot = this.transferSnapshot(transfer)
|
|
779
|
+
this.pushLog("transfer:saved", { transferId: transfer.id, path: transfer.savedPath })
|
|
780
|
+
this.emit({ type: "saved", transfer: snapshot })
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private autoSaveTransfer(transferId: string) {
|
|
784
|
+
void this.saveTransfer(transferId).catch(error => {
|
|
785
|
+
this.pushLog("transfer:save-error", { transferId, error: `${error}` }, "error")
|
|
786
|
+
this.notify()
|
|
787
|
+
})
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
private async createIncomingDiskState(fileName: string): Promise<IncomingDiskState> {
|
|
791
|
+
const finalPath = await uniqueOutputPath(this.saveDir, fileName || "download", this.reservedSavePaths)
|
|
792
|
+
this.reservedSavePaths.add(finalPath)
|
|
793
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
794
|
+
const tempPath = `${finalPath}.part.${uid(6)}${attempt ? `.${attempt}` : ""}`
|
|
795
|
+
try {
|
|
796
|
+
const handle = await open(tempPath, "wx")
|
|
797
|
+
return {
|
|
798
|
+
finalPath,
|
|
799
|
+
tempPath,
|
|
800
|
+
handle,
|
|
801
|
+
queue: Promise.resolve(),
|
|
802
|
+
offset: 0,
|
|
803
|
+
error: "",
|
|
804
|
+
closed: false,
|
|
805
|
+
}
|
|
806
|
+
} catch (error) {
|
|
807
|
+
if ((error as NodeJS.ErrnoException | undefined)?.code === "EEXIST") continue
|
|
808
|
+
this.reservedSavePaths.delete(finalPath)
|
|
809
|
+
throw error
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private takeIncomingDisk(transfer: TransferState) {
|
|
815
|
+
const disk = transfer.incomingDisk
|
|
816
|
+
transfer.incomingDisk = undefined
|
|
817
|
+
return disk
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
private async closeIncomingDiskState(disk: IncomingDiskState, removeTemp: boolean) {
|
|
821
|
+
if (!disk.closed) {
|
|
822
|
+
disk.closed = true
|
|
823
|
+
try { await disk.handle.close() } catch {}
|
|
824
|
+
}
|
|
825
|
+
if (removeTemp) await removePath(disk.tempPath)
|
|
826
|
+
this.reservedSavePaths.delete(disk.finalPath)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
private discardIncomingDiskTransfer(transfer: TransferState) {
|
|
830
|
+
const disk = this.takeIncomingDisk(transfer)
|
|
831
|
+
if (!disk) return
|
|
832
|
+
void this.closeIncomingDiskState(disk, true)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private async startIncomingDiskTransfer(transfer: TransferState) {
|
|
836
|
+
if (!this.autoSaveIncoming || transfer.incomingDisk) return false
|
|
837
|
+
try {
|
|
838
|
+
transfer.incomingDisk = await this.createIncomingDiskState(transfer.name)
|
|
839
|
+
return true
|
|
840
|
+
} catch (error) {
|
|
841
|
+
this.pushLog("transfer:save-error", { transferId: transfer.id, error: `${error}` }, "error")
|
|
842
|
+
return false
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
private queueIncomingDiskWrite(peer: PeerState, transfer: TransferState, data: Buffer) {
|
|
847
|
+
const disk = transfer.incomingDisk
|
|
848
|
+
if (!disk) return
|
|
849
|
+
disk.queue = disk.queue.then(async () => {
|
|
850
|
+
if (disk.closed || disk.error || transfer.cancel || transfer.status !== "receiving") return
|
|
851
|
+
await writeFileChunk(disk.handle, data, disk.offset)
|
|
852
|
+
disk.offset += data.byteLength
|
|
853
|
+
}).catch(error => {
|
|
854
|
+
if (disk.error || isFinal(transfer)) return
|
|
855
|
+
disk.error = `${error}`
|
|
856
|
+
this.pushLog("transfer:save-error", { transferId: transfer.id, error: disk.error }, "error")
|
|
857
|
+
this.sendDataControl(peer, { kind: "file-error", transferId: transfer.id, reason: disk.error })
|
|
858
|
+
this.completeTransfer(transfer, "error", disk.error)
|
|
859
|
+
})
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private async finalizeIncomingDiskTransfer(transfer: TransferState, expectedSize: number) {
|
|
863
|
+
const disk = this.takeIncomingDisk(transfer)
|
|
864
|
+
if (!disk) return null
|
|
865
|
+
await disk.queue
|
|
866
|
+
if (disk.error) {
|
|
867
|
+
await this.closeIncomingDiskState(disk, true)
|
|
868
|
+
return null
|
|
869
|
+
}
|
|
870
|
+
if (disk.offset !== expectedSize) throw new Error(`saved size mismatch: ${disk.offset} vs ${expectedSize}`)
|
|
871
|
+
|
|
872
|
+
let finalPath = disk.finalPath
|
|
873
|
+
try {
|
|
874
|
+
if (!disk.closed) {
|
|
875
|
+
disk.closed = true
|
|
876
|
+
await disk.handle.close()
|
|
877
|
+
}
|
|
878
|
+
if (await pathExists(finalPath)) {
|
|
879
|
+
this.reservedSavePaths.delete(finalPath)
|
|
880
|
+
finalPath = await uniqueOutputPath(this.saveDir, transfer.name || "download", this.reservedSavePaths)
|
|
881
|
+
this.reservedSavePaths.add(finalPath)
|
|
882
|
+
}
|
|
883
|
+
await rename(disk.tempPath, finalPath)
|
|
884
|
+
transfer.savedPath = finalPath
|
|
885
|
+
transfer.savedAt ||= Date.now()
|
|
886
|
+
return finalPath
|
|
887
|
+
} catch (error) {
|
|
888
|
+
await removePath(disk.tempPath)
|
|
889
|
+
throw error
|
|
890
|
+
} finally {
|
|
891
|
+
this.reservedSavePaths.delete(finalPath)
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
756
895
|
async waitFor(predicate: () => boolean, timeoutMs: number, signal?: AbortSignal | null) {
|
|
757
896
|
if (predicate()) return
|
|
758
897
|
if (signal?.aborted) throw new SessionAbortedError()
|
|
@@ -930,9 +1069,11 @@ export class SendSession {
|
|
|
930
1069
|
transfer.inFlight = false
|
|
931
1070
|
transfer.endedAt = Date.now()
|
|
932
1071
|
this.noteTransfer(transfer)
|
|
1072
|
+
if (transfer.direction === "out") void closeLocalFile(transfer.file).catch(() => {})
|
|
933
1073
|
if (status !== "complete" && transfer.direction === "in") {
|
|
934
1074
|
transfer.buffers = []
|
|
935
1075
|
transfer.data = undefined
|
|
1076
|
+
this.discardIncomingDiskTransfer(transfer)
|
|
936
1077
|
}
|
|
937
1078
|
|
|
938
1079
|
const peer = this.peers.get(transfer.peerId)
|
|
@@ -945,7 +1086,7 @@ export class SendSession {
|
|
|
945
1086
|
|
|
946
1087
|
const snapshot = this.transferSnapshot(transfer)
|
|
947
1088
|
this.emit({ type: "transfer", transfer: snapshot })
|
|
948
|
-
if (status === "complete" && transfer.direction === "in" && this.autoSaveIncoming)
|
|
1089
|
+
if (status === "complete" && transfer.direction === "in" && this.autoSaveIncoming && transfer.savedAt === 0) this.autoSaveTransfer(transfer.id)
|
|
949
1090
|
this.notify()
|
|
950
1091
|
}
|
|
951
1092
|
|
|
@@ -1036,6 +1177,7 @@ export class SendSession {
|
|
|
1036
1177
|
autoAcceptIncoming: this.autoAcceptIncoming,
|
|
1037
1178
|
autoSaveIncoming: this.autoSaveIncoming,
|
|
1038
1179
|
},
|
|
1180
|
+
streamingSaveIncoming: true,
|
|
1039
1181
|
})
|
|
1040
1182
|
}
|
|
1041
1183
|
|
|
@@ -1517,8 +1659,11 @@ export class SendSession {
|
|
|
1517
1659
|
private onBinary(peer: PeerState, data: Buffer) {
|
|
1518
1660
|
const transfer = this.transfers.get(peer.activeIncoming)
|
|
1519
1661
|
if (!transfer || transfer.status !== "receiving") return
|
|
1520
|
-
transfer.
|
|
1521
|
-
|
|
1662
|
+
if (transfer.incomingDisk) this.queueIncomingDiskWrite(peer, transfer, data)
|
|
1663
|
+
else {
|
|
1664
|
+
transfer.buffers ||= []
|
|
1665
|
+
transfer.buffers.push(data)
|
|
1666
|
+
}
|
|
1522
1667
|
transfer.bytes += data.byteLength
|
|
1523
1668
|
transfer.chunks += 1
|
|
1524
1669
|
this.noteTransfer(transfer)
|
|
@@ -1579,7 +1724,8 @@ export class SendSession {
|
|
|
1579
1724
|
peer.activeIncoming = transfer.id
|
|
1580
1725
|
transfer.status = "receiving"
|
|
1581
1726
|
transfer.startedAt ||= Date.now()
|
|
1582
|
-
transfer.
|
|
1727
|
+
transfer.data = undefined
|
|
1728
|
+
transfer.buffers = transfer.incomingDisk ? undefined : []
|
|
1583
1729
|
this.noteTransfer(transfer)
|
|
1584
1730
|
this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
|
|
1585
1731
|
}
|
|
@@ -1593,6 +1739,7 @@ export class SendSession {
|
|
|
1593
1739
|
case "file-end": {
|
|
1594
1740
|
const transfer = this.transfers.get(message.transferId)
|
|
1595
1741
|
if (!transfer || transfer.direction !== "in") break
|
|
1742
|
+
if (isFinal(transfer)) break
|
|
1596
1743
|
if (transfer.status === "cancelling") {
|
|
1597
1744
|
this.completeTransfer(transfer, "cancelled", transfer.cancelReason || "cancelled")
|
|
1598
1745
|
break
|
|
@@ -1601,6 +1748,27 @@ export class SendSession {
|
|
|
1601
1748
|
this.completeTransfer(transfer, "error", `unexpected end while ${transfer.status}`)
|
|
1602
1749
|
break
|
|
1603
1750
|
}
|
|
1751
|
+
if (transfer.bytes !== message.size) {
|
|
1752
|
+
const reason = `size mismatch: ${transfer.bytes} vs ${message.size}`
|
|
1753
|
+
this.sendDataControl(peer, { kind: "file-error", transferId: transfer.id, reason })
|
|
1754
|
+
this.completeTransfer(transfer, "error", reason)
|
|
1755
|
+
break
|
|
1756
|
+
}
|
|
1757
|
+
if (transfer.incomingDisk) {
|
|
1758
|
+
try {
|
|
1759
|
+
const savedPath = await this.finalizeIncomingDiskTransfer(transfer, message.size)
|
|
1760
|
+
if (!savedPath) break
|
|
1761
|
+
this.sendDataControl(peer, { kind: "file-done", transferId: transfer.id, size: transfer.bytes, totalChunks: transfer.chunks })
|
|
1762
|
+
this.completeTransfer(transfer, "complete")
|
|
1763
|
+
this.emitTransferSaved(transfer)
|
|
1764
|
+
} catch (error) {
|
|
1765
|
+
const reason = `${error}`
|
|
1766
|
+
this.pushLog("transfer:save-error", { transferId: transfer.id, error: reason }, "error")
|
|
1767
|
+
this.sendDataControl(peer, { kind: "file-error", transferId: transfer.id, reason })
|
|
1768
|
+
this.completeTransfer(transfer, "error", reason)
|
|
1769
|
+
}
|
|
1770
|
+
break
|
|
1771
|
+
}
|
|
1604
1772
|
const data = Buffer.concat(transfer.buffers || [])
|
|
1605
1773
|
if (data.byteLength !== message.size) {
|
|
1606
1774
|
const reason = `size mismatch: ${data.byteLength} vs ${message.size}`
|
package/src/index.ts
CHANGED
|
@@ -74,6 +74,7 @@ const TUI_TOGGLE_OPTIONS = [
|
|
|
74
74
|
["--offer <0|1>", "auto-offer drafts to matching ready peers: 1 on, 0 off"],
|
|
75
75
|
["--save <0|1>", "auto-save completed incoming files: 1 on, 0 off"],
|
|
76
76
|
] as const
|
|
77
|
+
export const ACCEPT_SESSION_DEFAULTS = { autoAcceptIncoming: true, autoSaveIncoming: true } as const
|
|
77
78
|
const addOptions = (command: CliCommand, definitions: readonly (readonly [string, string])[]) =>
|
|
78
79
|
definitions.reduce((next, [flag, description]) => next.option(flag, description), command)
|
|
79
80
|
|
|
@@ -233,7 +234,7 @@ const offerCommand = async (files: string[], options: Record<string, unknown>) =
|
|
|
233
234
|
|
|
234
235
|
const acceptCommand = async (options: Record<string, unknown>) => {
|
|
235
236
|
const { SendSession } = await loadSessionRuntime()
|
|
236
|
-
const session = new SendSession(sessionConfigFrom(options,
|
|
237
|
+
const session = new SendSession(sessionConfigFrom(options, ACCEPT_SESSION_DEFAULTS))
|
|
237
238
|
handleSignals(session)
|
|
238
239
|
printRoomAnnouncement(session.room, displayPeerName(session.name, session.localId), !!options.json)
|
|
239
240
|
const detachReporter = attachReporter(session, !!options.json)
|
|
@@ -253,7 +254,7 @@ const acceptCommand = async (options: Record<string, unknown>) => {
|
|
|
253
254
|
}
|
|
254
255
|
|
|
255
256
|
const tuiCommand = async (options: Record<string, unknown>) => {
|
|
256
|
-
const initialConfig = sessionConfigFrom(options,
|
|
257
|
+
const initialConfig = sessionConfigFrom(options, ACCEPT_SESSION_DEFAULTS)
|
|
257
258
|
const { clean, offer } = parseBinaryOptions(options, ["clean", "offer"] as const)
|
|
258
259
|
const { startTui } = await loadTuiRuntime()
|
|
259
260
|
await startTui(initialConfig, {
|
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)
|