Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 37 additions & 8 deletions native_tui/apps/NativeTui.Host/ExternalEditorLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,18 @@ public static class ExternalEditorLauncher
}
}

/// <summary>解析并启动编辑器进程 — $EDITOR 环境变量(支持带参数形态如 "code -w",
/// 按空白拆分首段为可执行名 + 余段前置参数 + temp 文件路径尾参);未设置时平台兜底
/// <summary>解析并启动编辑器进程 — $EDITOR 环境变量(支持带参数形态如 "code -w" 与
/// 引号路径形态如 "C:\Program Files\editor\e.exe" -w — 经 ParseEditorCommand 手写扫描解析;
/// 旧空白 Split 会把含空格路径拆成残引号断路径);未设置时平台兜底
///(Windows notepad.exe / 其余 vi)。启动失败返回 null。</summary>
private static Process? StartEditor(string filePath)
{
var editor = Environment.GetEnvironmentVariable("EDITOR");
var fileName = string.IsNullOrWhiteSpace(editor)
// M1 — 引号路径解析(解析后仍异常的形态由下方 catch 统一 null 降级)
var (exe, args) = ParseEditorCommand(Environment.GetEnvironmentVariable("EDITOR") ?? string.Empty);
var fileName = exe.Length == 0
? (OperatingSystem.IsWindows() ? "notepad.exe" : "vi")
: editor.Trim().Split(' ', 2)[0];
var prefixArguments = string.IsNullOrWhiteSpace(editor) || !editor.Contains(' ')
? string.Empty
: editor[(editor.IndexOf(' ') + 1)..].Trim() + " ";
: exe;
var prefixArguments = args.Length == 0 ? string.Empty : args + " ";

try
{
Expand Down Expand Up @@ -86,4 +86,33 @@ public static class ExternalEditorLauncher
return null;
}
}

/// <summary>解析 $EDITOR 命令串为可执行名 + 前置参数(M1 — 引号路径支持):
/// 手写扫描替代空白 Split — 跳过前导空白 → 首字符为引号时读闭合引号内段为可执行名
///(残缺引号防御:到串尾未闭合则引号后整段当 exe — 含空格启动失败由 StartEditor 容错降级)→
/// 否则读到首个空白为可执行名(shell 惯例 — 无引号空格即分隔符,含空格路径需引号包裹)→
/// 余段(含引号原样透传 ProcessStartInfo.Arguments)Trim 为前置参数;
/// 空/全空白输入返回 ("", "") 由调用方走平台兜底。</summary>
internal static (string Exe, string Args) ParseEditorCommand(string editor)
{
var span = editor.AsSpan().TrimStart();
if (span.IsEmpty)
{
return (string.Empty, string.Empty);
}

if (span[0] == '"')
{
var rest = span[1..];
var close = rest.IndexOf('"');
return close < 0
? (rest.ToString(), string.Empty)
: (rest[..close].ToString(), rest[(close + 1)..].Trim().ToString());
}

var split = span.IndexOf(' ');
return split < 0
? (span.ToString(), string.Empty)
: (span[..split].ToString(), span[(split + 1)..].Trim().ToString());
}
}
40 changes: 38 additions & 2 deletions native_tui/src/NativeTui.Core/repl/ReplShell.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,13 @@ private void HandleExitCtrlD()
/// 元组 = (发起时原文, 编辑后文本 — null = 编辑器失败/取消丢弃信号)。</summary>
private readonly ConcurrentQueue<(string Original, string? Edited)> _externalEditorResults = new();

/// <summary>ctrl+x 前缀序列标记(M2 — 基准 defaultBindings.ts:83-84 ctrl+x ctrl+e →
/// chat:externalEditor 第二键位):ctrl+x 置位,紧随其后的 ctrl+e 消费触发外部编辑器;
/// HandleCtrlKey 入口捕获复位 — 任何其他 ctrl 键到达即作废前缀态(无超时简化:基准无超时,
/// 陈旧前缀由下一次 ctrl 键到达自然清除;ctrl+x 自身重新置位延续等待 — readline 前缀语义,
/// 可扩展承载后续 ctrl+x ctrl+k 等序列,本批次仅接编辑器不预埋)。</summary>
private bool _ctrlXPrefix;

/// <summary>主循环存活标记(internal — 退出安全测试断言面:Esc/Ctrl+C 永不置 false)。</summary>
internal bool IsRunning => _running;

Expand Down Expand Up @@ -784,6 +791,13 @@ internal void HandleRawKeyPress(KeyPress key)
/// ,其余 Emacs 编辑(Ctrl/Meta 词操作 + kill ring),控制键走编辑/提交/权限,字符插入。</summary>
private void HandleKeyPress(KeyPress key)
{
// M2 序列前缀清除 — 非 ctrl 键(打字/箭头/Esc 等)插断 ctrl+x 序列:
// 防陈旧前缀被后续 ctrl+e 误触外部编辑器(HandleCtrlKey 入口只清 ctrl 键路径)
if (!key.Ctrl && _ctrlXPrefix)
{
_ctrlXPrefix = false;
}

if (_permission.IsPending)
{
HandlePermissionKey(key);
Expand Down Expand Up @@ -993,9 +1007,17 @@ private void HandleKeyPress(KeyPress key)
}
}

/// <summary>Ctrl 组合分派(对齐基准 handleCtrl 表)。</summary>
/// <summary>Ctrl 组合分派(对齐基准 handleCtrl 表)。
/// M2 新增 ctrl+x ctrl+e 第二键位(基准 defaultBindings.ts:83-84 chat:externalEditor —
/// ctrl+x 为前缀键:置 _ctrlXPrefix 序列标记,紧随其后的 ctrl+e 触发外部编辑器;
/// 入口捕获复位 — 任何其他 ctrl 键到达即作废前缀态,无超时简化;
/// 前缀态优先于 End() 编辑语义 — 单独 ctrl+e 仍是行尾,语义不破坏)。</summary>
private void HandleCtrlKey(string name)
{
// ctrl+x 前缀捕获复位(M2 — 进入即复位:仅 "e" 分支消费触发;
// "x" 分支重新置位延续等待,其余 ctrl 键到达即作废前缀态)
var ctrlXPrefix = _ctrlXPrefix;
_ctrlXPrefix = false;
switch (name)
{
case "a":
Expand All @@ -1019,7 +1041,16 @@ private void HandleCtrlKey(string name)

break;
case "e":
_input.End();
// M2 — 前缀态优先劫持进外部编辑器;非前缀保持 End() 编辑语义(单独 ctrl+e 仍是行尾)
if (ctrlXPrefix)
{
LaunchExternalEditor();
}
else
{
_input.End();
}

break;
case "f":
_input.Right();
Expand Down Expand Up @@ -1060,6 +1091,11 @@ private void HandleCtrlKey(string name)
case "w":
_input.KillWordBefore();
break;
case "x":
// M2 — ctrl+x 前缀键(仅接 ctrl+x ctrl+e 编辑器;基准 killAgents ctrl+x ctrl+k
// 未做不预埋 — 序列标记 _ctrlXPrefix 可扩展承载后续序列)
_ctrlXPrefix = true;
break;
case "y":
_input.Yank();
break;
Expand Down
106 changes: 85 additions & 21 deletions native_tui/src/NativeTui.Core/repl/permissions/PermissionTextWrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@ namespace NativeTui.Core.Repl;

/// <summary>
/// 权限对话框文本折行(S8)— 命令全文等宽展示的词边界折行纯函数:
/// 显式换行符先拆段,各段按宽度 word-wrap(空格分词 + 单词超宽硬切)。
/// 消费方:ToolPermissionForms 命令区折行(MeasureHeight 与 Render 同参数保证行数一致)。
/// 显式换行符先拆段,各段按显示列宽 word-wrap(空格分词 + 单词超宽硬切 — 复用
/// Grid.RuneWidth East Asian Width 判定,CJK/全角/emoji 2 列、组合符 0 列,ASCII 恒 1 列)。
/// 消费方:ToolPermissionForms 命令区折行(MeasureHeight 与 Render 同参数保证行数一致)
/// + BackgroundTaskDetailDialog 描述折行。
/// </summary>
public static class PermissionTextWrap
{
/// <summary>
/// 折行文本 — 显式 '\n' 拆段后各段 word-wrap;空文本返回空数组。
/// 折行文本 — 显式 '\n' 拆段后各段按显示列宽 word-wrap;空文本返回空数组。
/// width &lt;= 0 时防御性单行原样返回(调用方宽度契约破坏时不丢内容)。
/// </summary>
/// <param name="text">待折行文本(可含显式换行符)。</param>
/// <param name="width">目标行宽(显示列数)。</param>
/// <param name="width">目标行宽(显示列数 — 宽字符占 2 列)。</param>
/// <returns>折行后的行集(无尾随空格)。</returns>
public static string[] Wrap(string text, int width)
{
Expand All @@ -31,63 +33,125 @@ public static string[] Wrap(string text, int width)
.ToArray();
}

/// <summary>单段 word-wrap — 空格分词逐词拼接,当前行放不下则换行;单词本身超宽按宽度硬切。</summary>
/// <summary>单段 word-wrap — 空格分词逐词拼接,当前行放不下则换行;单词本身超宽按显示列宽硬切。</summary>
private static IEnumerable<string> WrapSegment(string segment, int width)
{
var lines = new List<string>();
var current = new StringBuilder();
var current = new LineBuffer();
foreach (var word in segment.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
AppendWord(lines, current, word, width);
}

if (current.Length > 0 || lines.Count == 0)
if (!current.IsEmpty || lines.Count == 0)
{
lines.Add(current.ToString());
}

return lines;
}

/// <summary>追加单词 — 能放下则拼接(带间隔空格),放不下则当前行落盘开新行;超宽单词硬切片。</summary>
private static void AppendWord(List<string> lines, StringBuilder current, string word, int width)
/// <summary>追加单词 — 能放下则拼接(带间隔空格),放不下则当前行落盘开新行;超宽单词按显示列宽硬切片。</summary>
private static void AppendWord(List<string> lines, LineBuffer current, string word, int width)
{
if (word.Length > width)
var wordWidth = RuneWidth.OfText(word);
if (wordWidth > width)
{
FlushCurrent(lines, current);
lines.AddRange(ChunkWord(word, width));
return;
}

var needed = current.Length == 0 ? word.Length : current.Length + 1 + word.Length;
var needed = current.IsEmpty ? wordWidth : current.Width + 1 + wordWidth;
if (needed > width)
{
FlushCurrent(lines, current);
}
else if (current.Length > 0)
else if (!current.IsEmpty)
{
current.Append(' ');
current.AppendSeparator();
}

current.Append(word);
current.Append(word, wordWidth);
}

/// <summary>当前行落盘(非空时)并清空缓冲。</summary>
private static void FlushCurrent(List<string> lines, StringBuilder current)
private static void FlushCurrent(List<string> lines, LineBuffer current)
{
if (current.Length > 0)
if (current.Flush() is { } line)
{
lines.Add(current.ToString());
current.Clear();
lines.Add(line);
}
}

/// <summary>超宽单词硬切 — 按 width 等宽切片。</summary>
/// <summary>超宽单词硬切 — 按显示列宽逐 rune 累计切片(切点取不超 width 的最大字符前缀);
/// 行首保底收 1 个 rune(width&lt;2 遇宽字符防空切片死循环 — 超宽尾列由 Screen 截断兜底,不丢内容);
/// 零宽 rune(组合符/变体选择器)不占列,跟随基字符同片不被切开。</summary>
private static IEnumerable<string> ChunkWord(string word, int width)
{
for (var offset = 0; offset < word.Length; offset += width)
var start = 0;
var column = 0;
var cursor = 0;
foreach (var rune in word.EnumerateRunes())
{
var runeWidth = RuneWidth.Of(rune);
if (column > 0 && column + runeWidth > width)
{
yield return word.Substring(start, cursor - start);
start = cursor;
column = 0;
}

column += runeWidth;
cursor += rune.Utf16SequenceLength;
}

if (cursor > start)
{
yield return word.Substring(offset, Math.Min(width, word.Length - offset));
yield return word.Substring(start, cursor - start);
}
}

/// <summary>当前行缓冲 — 文本与显示列宽同步累计(追加即累宽、落盘后归零,免重复测宽遍历)。</summary>
private sealed class LineBuffer
{
private readonly StringBuilder _text = new();

/// <summary>当前行显示列宽(含词间空格 1 列)。</summary>
public int Width { get; private set; }

/// <summary>当前行是否为空(无任何字符)。</summary>
public bool IsEmpty => _text.Length == 0;

/// <summary>追加词间间隔空格(占 1 显示列)。</summary>
public void AppendSeparator()
{
_text.Append(' ');
Width += 1;
}

/// <summary>追加词文本(显示列宽由调用方一次测得传入 — 免二次遍历)。</summary>
public void Append(string text, int textWidth)
{
_text.Append(text);
Width += textWidth;
}

/// <summary>落盘当前行并重置(空行返回 null 不落盘)。</summary>
public string? Flush()
{
if (_text.Length == 0)
{
return null;
}

var line = _text.ToString();
_text.Clear();
Width = 0;
return line;
}

/// <summary>当前行文本(空行即空串 — 空段落盘占位)。</summary>
public override string ToString() => _text.ToString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
namespace NativeTui.Host.Tests;

/// <summary>
/// ExternalEditorLauncher.ParseEditorCommand 纯函数测试(M1 — $EDITOR 引号路径解析):
/// 常规形态(空输入兜底/无参数/带参数/前后空白)+ 引号形态(含空格路径+参数/只有引号路径/
/// 残缺引号到串尾/多参数原样/参数内嵌引号原样透传)。纯函数无进程依赖 —
/// StartEditor 进程启动面不在单测覆盖(对齐 Core 侧 ExternalEditorTests 假编辑器委托分层)。
/// </summary>
public sealed class ExternalEditorLauncherTests
{
/// <summary>常规形态 — 无引号无反斜杠(InlineData 源串可读直书)。</summary>
[Theory]
[InlineData("", "", "")]
[InlineData(" ", "", "")]
[InlineData("code", "code", "")]
[InlineData("code -w", "code", "-w")]
[InlineData(" code -w ", "code", "-w")]
public void ParseEditorCommand_PlainForms_ResolvesExeAndArgs(string editor, string expectedExe, string expectedArgs)
{
var (exe, args) = ExternalEditorLauncher.ParseEditorCommand(editor);

Assert.Equal(expectedExe, exe);
Assert.Equal(expectedArgs, args);
}

/// <summary>引号路径+参数 — 含空格路径剥离引号完整作 exe(旧空白 Split 拆坏形态的修复目标)。</summary>
[Fact]
public void ParseEditorCommand_QuotedPathWithArgs_ResolvesFullPath()
{
var (exe, args) = ExternalEditorLauncher.ParseEditorCommand("\"C:\\Program Files\\editor\\e.exe\" -w");

Assert.Equal("C:\\Program Files\\editor\\e.exe", exe);
Assert.Equal("-w", args);
}

/// <summary>只有引号路径 — 完整路径作 exe,无参数。</summary>
[Fact]
public void ParseEditorCommand_QuotedPathOnly_ResolvesFullPathWithoutArgs()
{
var (exe, args) = ExternalEditorLauncher.ParseEditorCommand("\"C:\\Program Files\\editor\\e.exe\"");

Assert.Equal("C:\\Program Files\\editor\\e.exe", exe);
Assert.Equal(string.Empty, args);
}

/// <summary>残缺引号到串尾 — 引号后整段当 exe(防御不炸解析;含空格启动失败由 StartEditor 容错降级)。</summary>
[Fact]
public void ParseEditorCommand_UnclosedQuote_TreatsRemainderAsExe()
{
var (exe, args) = ExternalEditorLauncher.ParseEditorCommand("\"C:\\Program Files\\e.exe -w");

Assert.Equal("C:\\Program Files\\e.exe -w", exe);
Assert.Equal(string.Empty, args);
}

/// <summary>引号路径 + 多参数 — 余段原样作 args(中间空白保留)。</summary>
[Fact]
public void ParseEditorCommand_QuotedPathWithMultipleArgs_PassesArgsVerbatim()
{
var (exe, args) = ExternalEditorLauncher.ParseEditorCommand("\"e.exe\" -w --goto file.md");

Assert.Equal("e.exe", exe);
Assert.Equal("-w --goto file.md", args);
}

/// <summary>参数内嵌引号 — 原样透传(ProcessStartInfo.Arguments 自行处理参数引用)。</summary>
[Fact]
public void ParseEditorCommand_ArgsWithEmbeddedQuotes_PassesVerbatim()
{
var (exe, args) = ExternalEditorLauncher.ParseEditorCommand("\"e.exe\" -c \"set ts=4\"");

Assert.Equal("e.exe", exe);
Assert.Equal("-c \"set ts=4\"", args);
}

/// <summary>前导空白 + 引号路径 — TrimStart 后走引号分支正常解析。</summary>
[Fact]
public void ParseEditorCommand_LeadingSpacesBeforeQuotedPath_SkipsSpaces()
{
var (exe, args) = ExternalEditorLauncher.ParseEditorCommand(" \"C:\\Program Files\\e.exe\" -w");

Assert.Equal("C:\\Program Files\\e.exe", exe);
Assert.Equal("-w", args);
}
}
Loading