From 6fa83574e1442ec05c4e369348290a967a66194d Mon Sep 17 00:00:00 2001 From: 771-8bit Date: Thu, 5 Nov 2026 14:38:54 +0900 Subject: [PATCH 01/11] =?UTF-8?q?fix(serial):=20CR=20=E5=8D=98=E7=8B=AC?= =?UTF-8?q?=E3=81=AE=E6=94=B9=E8=A1=8C=E3=82=82=E8=A1=8C=E3=81=AE=E5=8C=BA?= =?UTF-8?q?=E5=88=87=E3=82=8A=E3=81=AB=E3=81=99=E3=82=8B=EF=BC=88C#=20?= =?UTF-8?q?=E7=89=88=E3=81=AE=E3=83=87=E3=82=B0=E3=83=AC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C# 版は CR だけの改行も改行として表示していたが、行索引が LF しか見ておらず、 CR で行を終える機器の出力が 1 行に繋がっていた。 - LF・CR・CRLF をそれぞれ 1 つの改行とする(CRLF は 1 つ、LF CR と CR CR は 2 つ) - CR の次が LF かはチャンクをまたいで判定する。チャンク末尾の CR は次のバイトまで 保留し、索引が縮むことはない - ResumeBoundary を LineIndexer にまとめ、保留中の CR も持たせる。切断で保留が 失われても再開位置が行頭として入る Co-Authored-By: Claude Opus 5.5 --- docs/21_system_requirements.md | 2 +- src-tauri/src/serial/worker_thread.rs | 139 +++++++++++++++++++------- 2 files changed, 102 insertions(+), 39 deletions(-) diff --git a/docs/21_system_requirements.md b/docs/21_system_requirements.md index 6b64a70..f9b375f 100644 --- a/docs/21_system_requirements.md +++ b/docs/21_system_requirements.md @@ -68,7 +68,7 @@ ISO/IEC/IEEE 29148 の要求記述スタイル(一意な ID、shall 形式、 | **SYS-F-205** | システムは、ディスク書き込みに失敗した場合でも該当データを失わず、再試行の対象として保持し続けること。またディスクフル等のエラーを利用者に通知すること。通知は同種のエラーが連続しても 5 秒に 1 回までに抑えること | 書き込み失敗でデータが消えるのは SYS-F-201 違反。ディスクフルは毎周期発生するため、抑制しないと通知が画面を埋める | UN-10 | T, I | 実装済(*2) | | **SYS-F-206** | システムは、受信バイト総数の変化をフロントエンドへ通知すること。通知頻度は約 60 通知/秒(16 ms 間隔)を上限に間引くこと | 12 Mbps では 1 バイトごとの通知は不可能。UI 側の更新契機だけが必要 | UN-15 | I, D | 実装済 | | **SYS-F-207** | システムは、受信データに対して約 100 ms 間隔で受信時刻を記録し、任意のバイトオフセットに対応する時刻を取得できること | ASCII ビューアのタイムスタンプ列。全バイトに時刻を持たせるのは容量的に不可 | UN-07 | T | 実装済 | -| **SYS-F-208** | システムは、受信データ中の改行 (LF) 位置を受信時に索引化し、行番号からバイト範囲を取得できること | 行指向の ASCII 表示で、表示のたびに全走査しないため | UN-07 | T | 実装済 | +| **SYS-F-208** | システムは、受信データ中の改行位置を受信時に索引化し、行番号からバイト範囲を取得できること。改行は LF・CR・CRLF のいずれも 1 つの改行とし(CRLF は 1 つ、LF CR と CR CR は 2 つ)、チャンク境界をまたぐ CRLF も 1 つと数えること | 行指向の ASCII 表示で、表示のたびに全走査しないため。CR だけで改行する機器の出力が 1 行に繋がらないため(C# 版は CR 単独も改行として表示していた。2026-11-05 デグレとして修正) | UN-07 | T(`test_record_line_offsets_cr_rules` ほか) | 実装済(CR は 2026-11-05) | | **SYS-F-209** | システムは、一時ファイルをシステムの一時ディレクトリ配下に置き、**インスタンスごとに一意な**サブディレクトリを用いること。アプリ終了時に自分の領域を削除し、起動時に使用されていない過去の領域を削除すること | 利用者のディスクを汚さない。かつ、同一プロセス内の旧インスタンスの後始末が新インスタンスのライブデータを消さないため(2026-09-03 修正) | UN-14, UN-17, UN-21 | I, D | 実装済 | | **SYS-F-210** | システムは、受信済みの全データをバイナリのままファイルへエクスポートできること。エクスポート中もメモリ使用量が受信データ量に比例して増加しないこと | 外部ツールでの解析。GB 級のログを一度にメモリへ載せない | UN-13, UN-16 | I, D | 実装済 | | **SYS-F-211** | システムは、受信済みデータを Hex 文字列または ASCII 文字列としてクリップボードへ渡せること。大量データの場合は事前に利用者へ確認すること | 貼り付けて共有する運用。無警告のフリーズを避ける | UN-07 | I, D | 実装済 | diff --git a/src-tauri/src/serial/worker_thread.rs b/src-tauri/src/serial/worker_thread.rs index e794fa7..00cef4e 100644 --- a/src-tauri/src/serial/worker_thread.rs +++ b/src-tauri/src/serial/worker_thread.rs @@ -62,38 +62,62 @@ impl Drop for TimerResolution { } } -/// Record line offsets (LF positions) in the given data -fn record_line_offsets(line_index: &RwLock>, data: &[u8], global_offset: u64) { +/// Record the line starts in `data` (the bytes at `global_offset`). +/// +/// LF, CR and CRLF each end a line, as in the C# version: CRLF is one break, LF CR +/// and CR CR are two. Whether a CR is followed by LF is only known at the next byte, +/// so a CR that ends `data` stays pending in `pending_cr` and is resolved by the +/// next call (the index never has to shrink). Calls must be for contiguous data. +fn record_line_offsets( + line_index: &RwLock>, + data: &[u8], + global_offset: u64, + pending_cr: &mut bool, +) { if let Ok(mut index) = line_index.write() { + let mut push = |offset: u64| { + // 重複を避ける + if index.last().is_none_or(|&last| last < offset) { + index.push(offset); + } + }; for (i, &byte) in data.iter().enumerate() { - // LF (\n) を改行として検出 + let offset = global_offset + i as u64; + if *pending_cr && byte != b'\n' { + // A lone CR: the line ended before this byte + push(offset); + } + *pending_cr = byte == b'\r'; if byte == b'\n' { - let next_line_offset = global_offset + i as u64 + 1; - // 重複を避ける - if index.last().is_none_or(|&last| last < next_line_offset) { - index.push(next_line_offset); - } + push(offset + 1); } } } } +/// 受信データの行位置を line_index に記録する(CR 単独の改行を判定するため、チャンクを +/// またいで「直前が CR だったか」を持つ)。 +/// /// 再接続で同じストアに追記を再開したとき、切断で途切れた行と再開後の最初の行が -/// 1 行に繋がって見えないよう、再開位置を行頭として 1 度だけ記録する。 +/// 1 行に繋がって見えないよう、再開位置を行頭として 1 度だけ記録する。切断直前の +/// バイトが CR で保留のままだった場合も、ここで行頭が入る。 /// 受信バイトそのものは変えない(line_index は表示用の索引)。 -struct ResumeBoundary(Option); +struct LineIndexer { + resume: Option, + pending_cr: bool, +} -impl ResumeBoundary { +impl LineIndexer { fn new(line_index: &RwLock>, start_offset: u64) -> Self { // 直前のバイトが LF なら start_offset は既に行頭として記録されている let already_line_start = line_index .read() .map(|index| index.last().is_some_and(|&last| last >= start_offset)) .unwrap_or(true); - if start_offset == 0 || already_line_start { - Self(None) - } else { - Self(Some(start_offset)) + let resume = (start_offset != 0 && !already_line_start).then_some(start_offset); + Self { + resume, + pending_cr: false, } } @@ -103,14 +127,19 @@ impl ResumeBoundary { if !chunk.has_data() { return; } - if let Some(boundary) = self.0.take() { + if let Some(boundary) = self.resume.take() { if let Ok(mut index) = line_index.write() { if index.last().is_none_or(|&last| last < boundary) { index.push(boundary); } } } - record_line_offsets(line_index, chunk.data(), chunk.global_offset()); + record_line_offsets( + line_index, + chunk.data(), + chunk.global_offset(), + &mut self.pending_cr, + ); } } @@ -149,7 +178,7 @@ pub fn spawn_worker_thread( // グローバルオフセット。新しいストアなら 0、自動再接続で同じストアに // 追記を再開したときは前回までの総バイト数から続ける let mut global_offset = start_offset; - let mut boundary = ResumeBoundary::new(&line_index, start_offset); + let mut boundary = LineIndexer::new(&line_index, start_offset); let mut loop_count = 0u64; let mut last_status_log = Instant::now(); // 受信が止まる時間の診断(5 秒窓の最大値)。フロー制御の無い UART では @@ -613,7 +642,7 @@ mod tests { #[test] fn test_resume_boundary_splits_cut_line() { let index = RwLock::new(vec![0, 4]); // "abc\nde" の後で切断(total 6) - let mut boundary = ResumeBoundary::new(&index, 6); + let mut boundary = LineIndexer::new(&index, 6); boundary.record(&index, &chunk_at(6, b"")); // 空チャンクでは入れない assert_eq!(*index.read().unwrap(), vec![0, 4]); boundary.record(&index, &chunk_at(6, b"xy\n")); @@ -625,16 +654,28 @@ mod tests { #[test] fn test_resume_boundary_not_needed_after_newline() { let index = RwLock::new(vec![0, 4]); // "abc\n" の後で切断(total 4) - let mut boundary = ResumeBoundary::new(&index, 4); + let mut boundary = LineIndexer::new(&index, 4); boundary.record(&index, &chunk_at(4, b"x\n")); assert_eq!(*index.read().unwrap(), vec![0, 4, 6]); } + /// 切断直前が CR(保留のまま)でも、再開位置が行頭として入る + #[test] + fn test_resume_after_pending_cr() { + let index = RwLock::new(vec![0]); + let mut first = LineIndexer::new(&index, 0); + first.record(&index, &chunk_at(0, b"ab\r")); // CR pending when the worker stops + assert_eq!(*index.read().unwrap(), vec![0]); + let mut resumed = LineIndexer::new(&index, 3); + resumed.record(&index, &chunk_at(3, b"cd\r\n")); + assert_eq!(*index.read().unwrap(), vec![0, 3, 7]); + } + /// 新しいストア(start_offset 0)では何もしない #[test] fn test_resume_boundary_noop_for_new_store() { let index = RwLock::new(vec![0]); - let mut boundary = ResumeBoundary::new(&index, 0); + let mut boundary = LineIndexer::new(&index, 0); boundary.record(&index, &chunk_at(0, b"a\nb")); assert_eq!(*index.read().unwrap(), vec![0, 2]); } @@ -645,7 +686,7 @@ mod tests { let line_index = Arc::new(RwLock::new(vec![0u64])); let data = b"Hello\nWorld\n"; - record_line_offsets(&line_index, data, 0); + record_line_offsets(&line_index, data, 0, &mut false); let index = line_index.read().unwrap(); // Initial [0] + 2 newlines: [0, 6, 12] @@ -661,7 +702,7 @@ mod tests { let line_index = Arc::new(RwLock::new(vec![0u64])); let data = b"Line\n"; - record_line_offsets(&line_index, data, 100); + record_line_offsets(&line_index, data, 100, &mut false); let index = line_index.read().unwrap(); assert_eq!(index.len(), 2); @@ -675,8 +716,8 @@ mod tests { let line_index = Arc::new(RwLock::new(vec![0u64])); let data = b"A\n"; - record_line_offsets(&line_index, data, 0); - record_line_offsets(&line_index, data, 0); // Same data again + record_line_offsets(&line_index, data, 0, &mut false); + record_line_offsets(&line_index, data, 0, &mut false); // Same data again let index = line_index.read().unwrap(); // Should not duplicate: [0, 2] @@ -689,7 +730,7 @@ mod tests { let line_index = Arc::new(RwLock::new(vec![0u64])); let data = b""; - record_line_offsets(&line_index, data, 0); + record_line_offsets(&line_index, data, 0, &mut false); let index = line_index.read().unwrap(); // No change: [0] @@ -702,7 +743,7 @@ mod tests { let line_index = Arc::new(RwLock::new(vec![0u64])); let data = b"NoNewlineHere"; - record_line_offsets(&line_index, data, 0); + record_line_offsets(&line_index, data, 0, &mut false); let index = line_index.read().unwrap(); // No change: [0] @@ -715,7 +756,7 @@ mod tests { let line_index = Arc::new(RwLock::new(vec![0u64])); let data = b"\n\n\n"; - record_line_offsets(&line_index, data, 0); + record_line_offsets(&line_index, data, 0, &mut false); let index = line_index.read().unwrap(); // [0, 1, 2, 3] @@ -726,17 +767,39 @@ mod tests { assert_eq!(index[3], 3); } - /// record_line_offsets should only detect LF, not CR - #[test] - fn test_record_line_offsets_ignores_cr() { - let line_index = Arc::new(RwLock::new(vec![0u64])); - let data = b"Hello\rWorld"; // CR only, no LF + /// Line starts for `parts` fed as consecutive chunks + fn index_of(parts: &[&[u8]]) -> Vec { + let line_index = RwLock::new(vec![0u64]); + let mut pending = false; + let mut offset = 0; + for part in parts { + record_line_offsets(&line_index, part, offset, &mut pending); + offset += part.len() as u64; + } + line_index.into_inner().unwrap() + } - record_line_offsets(&line_index, data, 0); + /// A lone CR ends a line (C# version), CRLF is one break, LF CR and CR CR are two + #[test] + fn test_record_line_offsets_cr_rules() { + assert_eq!(index_of(&[b"Hello\rWorld"]), vec![0, 6]); + assert_eq!(index_of(&[b"a\r\nb"]), vec![0, 3]); + assert_eq!(index_of(&[b"a\n\rb"]), vec![0, 2, 3]); + assert_eq!(index_of(&[b"a\r\rb"]), vec![0, 2, 3]); + assert_eq!(index_of(&[b"a\rb\rc\r"]), vec![0, 2, 4]); // last CR still pending + } - let index = line_index.read().unwrap(); - // No change: [0] - CR is not treated as line boundary - assert_eq!(index.len(), 1); + /// A CR at the end of a chunk is decided by the next chunk's first byte + #[test] + fn test_record_line_offsets_cr_across_chunks() { + assert_eq!(index_of(&[b"a\r", b"\nb"]), vec![0, 3]); + assert_eq!(index_of(&[b"a\r", b"b"]), vec![0, 2]); + assert_eq!(index_of(&[b"a\r", b"", b"b"]), vec![0, 2]); + assert_eq!(index_of(&[b"a\r", b"\r", b"\n"]), vec![0, 2, 4]); + // Byte by byte gives the same index as one chunk + let data = b"x\ry\r\nz\n\r\rw"; + let bytes: Vec<&[u8]> = data.chunks(1).collect(); + assert_eq!(index_of(&bytes), index_of(&[data])); } /// record_line_offsets should detect LF in CRLF sequences @@ -745,7 +808,7 @@ mod tests { let line_index = Arc::new(RwLock::new(vec![0u64])); let data = b"Hello\r\nWorld\r\n"; - record_line_offsets(&line_index, data, 0); + record_line_offsets(&line_index, data, 0, &mut false); let index = line_index.read().unwrap(); // [0, 7, 14] - LF at positions 6 and 13 From 5e538dad684ac2fd07c8e388e01f9be207ebda75 Mon Sep 17 00:00:00 2001 From: 771-8bit Date: Thu, 5 Nov 2026 14:39:12 +0900 Subject: [PATCH 02/11] =?UTF-8?q?fix(ports):=20=E3=83=9D=E3=83=BC=E3=83=88?= =?UTF-8?q?=E5=90=8D=E3=81=AB=20COM=20=E7=95=AA=E5=8F=B7=E3=81=8C=E4=BA=8C?= =?UTF-8?q?=E9=87=8D=E3=81=AB=E5=87=BA=E3=81=AA=E3=81=84=E3=82=88=E3=81=86?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows の USB 機器はフレンドリ名がすでに " (COM5)" で終わるため、一覧が "USB Serial Port (COM5) (COM5)" になっていた。製品名の末尾がそのポート名の 括弧書きなら取り除いてから "製品名 (COM5)" にする。形式は変えないので、 portPath / extract_port_path の解釈と MCP の ports もそのまま。 Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/serial/mod.rs | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/serial/mod.rs b/src-tauri/src/serial/mod.rs index a3bab63..aebc22f 100644 --- a/src-tauri/src/serial/mod.rs +++ b/src-tauri/src/serial/mod.rs @@ -533,6 +533,18 @@ pub fn list_ports() -> Vec { enumerate_ports() } +/// List entry for a USB port: "Product (COM5)". +/// +/// On Windows the product string is the device's friendly name, which already ends +/// in " (COM5)"; that suffix is dropped so the entry does not read +/// "USB Serial Port (COM5) (COM5)". +fn usb_port_label(product: Option<&str>, port_name: &str) -> String { + let product = product.unwrap_or("Unknown Device"); + let suffix = format!(" ({port_name})"); + let product = product.strip_suffix(suffix.as_str()).unwrap_or(product); + format!("{product} ({port_name})") +} + /// 利用可能なポートの列挙(`list_ports` コマンドの実体) /// /// AI ブリッジの `ports` メソッドが GUI と同じ一覧を返せるよう、コマンド属性の @@ -545,8 +557,7 @@ pub(crate) fn enumerate_ports() -> Vec { // Include friendly name for USB devices, or just the port name match &p.port_type { serialport::SerialPortType::UsbPort(info) => { - let product = info.product.as_deref().unwrap_or("Unknown Device"); - format!("{} ({})", product, p.port_name) + usb_port_label(info.product.as_deref(), &p.port_name) } serialport::SerialPortType::PciPort => { format!("PCI Device ({})", p.port_name) @@ -761,6 +772,28 @@ mod tests { assert_eq!(extract_port_path(""), ""); } + #[test] + fn usb_port_label_drops_the_duplicated_port_suffix() { + assert_eq!( + usb_port_label(Some("USB Serial Port (COM5)"), "COM5"), + "USB Serial Port (COM5)" + ); + assert_eq!( + usb_port_label(Some("CP2102 USB to UART"), "/dev/ttyUSB0"), + "CP2102 USB to UART (/dev/ttyUSB0)" + ); + assert_eq!(usb_port_label(None, "COM7"), "Unknown Device (COM7)"); + // Another port's number in the name is kept + assert_eq!( + usb_port_label(Some("Bridge (COM4)"), "COM5"), + "Bridge (COM4) (COM5)" + ); + assert_eq!( + extract_port_path(&usb_port_label(Some("X (COM5)"), "COM5")), + "COM5" + ); + } + #[test] fn test_byte_to_ascii_printable() { // Printable ASCII characters (0x20-0x7e) From 10d0b3e21f8752962f98c9a49fb81c2253abadd6 Mon Sep 17 00:00:00 2001 From: 771-8bit Date: Thu, 5 Nov 2026 14:39:23 +0900 Subject: [PATCH 03/11] =?UTF-8?q?fix(send):=20Hex=20=E9=80=81=E4=BF=A1?= =?UTF-8?q?=E3=81=A7=20C#=20=E7=89=88=E3=81=AE=E5=8C=BA=E5=88=87=E3=82=8A?= =?UTF-8?q?=E6=96=87=E5=AD=97=E3=81=A8=201=20=E6=A1=81=E3=81=AE=E3=83=90?= =?UTF-8?q?=E3=82=A4=E3=83=88=E3=82=92=E5=8F=97=E3=81=91=E4=BB=98=E3=81=91?= =?UTF-8?q?=E3=82=8B=EF=BC=88C#=20=E7=89=88=E3=81=AE=E3=83=87=E3=82=B0?= =?UTF-8?q?=E3=83=AC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C# 版は '-' ' ' ',' '.' '/' で区切り、各トークンを 1 バイト(1〜2 桁)として 読んでいた。新版は空白だけを無視して 2 桁ずつ組にしていたため、`0A-0B` は エラーになり、`12 3 4` は黙って 0x12 0x34 を送っていた(誤送信)。 - 空白・, - . / : で区切り、1〜2 桁のトークンを 1 バイトとする。0x 接頭辞も可 - 3 桁以上の偶数桁のトークンは 2 桁ずつ(`48656C6C6F` は従来どおり) - それ以外(`123` のような奇数桁、16 進でない文字)は推測せずにエラー。どの トークンかを入力欄の下に表示し、その間は Send を押せない(ブロックする alert をやめた) Co-Authored-By: Claude Opus 5.5 --- src/App.css | 14 +++++++- src/components/SendPanel.test.ts | 26 ++++++++++++++- src/components/SendPanel.tsx | 55 ++++++++++++++++++-------------- src/utils/sendUtils.ts | 55 +++++++++++++++++++++----------- 4 files changed, 106 insertions(+), 44 deletions(-) diff --git a/src/App.css b/src/App.css index 8082825..0e2ecce 100644 --- a/src/App.css +++ b/src/App.css @@ -278,8 +278,20 @@ button:disabled { gap: 8px; } -.send-input { +.send-input-column { flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.send-error { + color: var(--danger-text); + font-size: 12px; +} + +.send-input { height: 80px; resize: none; border: 1px solid var(--border); diff --git a/src/components/SendPanel.test.ts b/src/components/SendPanel.test.ts index c408ffc..8bc38ab 100644 --- a/src/components/SendPanel.test.ts +++ b/src/components/SendPanel.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseHexString, appendLineEnding, addToHistory } from '../utils/sendUtils'; +import { parseHex, parseHexString, appendLineEnding, addToHistory } from '../utils/sendUtils'; /** * SendPanel utility function tests @@ -38,6 +38,30 @@ describe('SendPanel - Hex Parsing', () => { it('handles single byte', () => { expect(parseHexString('FF')).toEqual([0xff]); }); + + // C# version: split at '-', ' ', ',', '.', '/', each token one byte + it('accepts the C# separators and one-digit bytes', () => { + expect(parseHexString('0A-0B')).toEqual([0x0a, 0x0b]); + expect(parseHexString('1,2,3')).toEqual([0x01, 0x02, 0x03]); + expect(parseHexString('a.b/c')).toEqual([0x0a, 0x0b, 0x0c]); + expect(parseHexString('01:02 03\t04\n05')).toEqual([1, 2, 3, 4, 5]); + expect(parseHexString('41-42-')).toEqual([0x41, 0x42]); // trailing separator + }); + + it('reads one-digit tokens as bytes instead of pairing digits across tokens', () => { + // Before, "12 3 4" was paired up as "1234" and sent 0x12 0x34 + expect(parseHexString('12 3 4')).toEqual([0x12, 0x03, 0x04]); + }); + + it('accepts a 0x prefix', () => { + expect(parseHexString('0x41 0X42 0x4344')).toEqual([0x41, 0x42, 0x43, 0x44]); + }); + + it('names the token it cannot read', () => { + expect(parseHex('41 123 42')).toEqual({ ok: false, token: '123' }); + expect(parseHex('41 G1')).toEqual({ ok: false, token: 'G1' }); + expect(parseHex('0x')).toEqual({ ok: false, token: '0x' }); + }); }); describe('SendPanel - Line Endings', () => { diff --git a/src/components/SendPanel.tsx b/src/components/SendPanel.tsx index 6ce6b54..97ebdf6 100644 --- a/src/components/SendPanel.tsx +++ b/src/components/SendPanel.tsx @@ -1,12 +1,7 @@ import { useState, useRef, KeyboardEvent } from 'react'; import { usePersistentState, isBoolean, isOneOf } from '../utils/persist'; import { invoke } from '@tauri-apps/api/core'; -import { - parseHexString, - appendLineEnding, - addToHistory, - type LineEnding, -} from '../utils/sendUtils'; +import { parseHex, appendLineEnding, addToHistory, type LineEnding } from '../utils/sendUtils'; interface SendPanelProps { connected: boolean; @@ -38,20 +33,20 @@ export default function SendPanel({ connected, onSend }: SendPanelProps) { // Unsent draft, stashed when entering history navigation const draftRef = useRef(''); + const hexMode = inputMode === 'HEX' || sendBinary; + // Checked while typing: the problem is shown under the input and Send stays disabled + const hexCheck = hexMode ? parseHex(inputText) : null; + const hexError = + hexCheck !== null && !hexCheck.ok ? `"${hexCheck.token}" is not a hex byte` : null; + const handleSend = async () => { - if (!connected || !inputText) return; + if (!connected || !inputText || hexError !== null) return; let dataToSend: number[] = []; try { - if (inputMode === 'HEX' || sendBinary) { - // sendBinary flag or HEX mode - const parsed = parseHexString(inputText); - if (parsed === null) { - alert('Invalid Hex: Odd number of characters or invalid hex'); - return; - } - dataToSend = parsed; + if (hexCheck !== null && hexCheck.ok) { + dataToSend = hexCheck.bytes; } else { // Convert string to bytes (UTF-8) const encoder = new TextEncoder(); @@ -115,16 +110,28 @@ export default function SendPanel({ connected, onSend }: SendPanelProps) {
Send
-