mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
Fix ghost autocomplete after Topsec backspace echo
This commit is contained in:
@@ -479,3 +479,217 @@ test("hides the ghost on render when the device echoed untracked input (#1013)",
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the ghost when TopsecOS-style backspace leaves stale text after the cursor (#1060)", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
// The user deleted back from "system..." to "syst", so the tracked
|
||||
// input is shorter and the ghost regrows to "em license show".
|
||||
addon.show("system license show", "syst");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
// TopsecOS devices shown in #1060 leave the old suffix visible after
|
||||
// the cursor while processing Backspace, so the real buffer looks like:
|
||||
// TopsecOS# syst|em license show
|
||||
const beforeCursor = "TopsecOS# syst";
|
||||
const line = `${beforeCursor}em license show`;
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = beforeCursor.length;
|
||||
active.getLine = () => ({ translateToString: () => line });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), false);
|
||||
assert.equal(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the ghost when only the deleted prefix remains after the cursor (#1060)", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("system license show", "syst");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
const beforeCursor = "TopsecOS# syst";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = beforeCursor.length;
|
||||
active.getLine = () => ({ translateToString: () => `${beforeCursor}em` });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), false);
|
||||
assert.equal(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the ghost when TopsecOS-style backspace leaves stale text after empty input (#1060)", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("system license show", "");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
const beforeCursor = "TopsecOS# ";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = beforeCursor.length;
|
||||
active.getLine = () => ({ translateToString: () => `${beforeCursor}system license show` });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), false);
|
||||
assert.equal(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the ghost when stale text is followed by right-side status text (#1060)", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("system license show", "syst");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
const beforeCursor = "TopsecOS# syst";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = beforeCursor.length;
|
||||
active.getLine = () => ({ translateToString: () => `${beforeCursor}em 12:34 ok` });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), false);
|
||||
assert.equal(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the ghost when a stale argument suffix starts with a space (#1060)", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("system license show", "system");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
const beforeCursor = "TopsecOS# system";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = beforeCursor.length;
|
||||
active.getLine = () => ({ translateToString: () => `${beforeCursor} license show` });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), false);
|
||||
assert.equal(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the ghost when unrelated right-side prompt text is visible", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("system license show", "syst");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
const beforeCursor = "host# syst";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = beforeCursor.length;
|
||||
active.getLine = () => ({ translateToString: () => `${beforeCursor} 12:34 ok` });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), true);
|
||||
assert.notEqual(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the ghost when only right-side spacing overlaps a space-prefixed suffix", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("system license show", "system");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
const beforeCursor = "host# system";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = beforeCursor.length;
|
||||
active.getLine = () => ({ translateToString: () => `${beforeCursor} 12:34 ok` });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), true);
|
||||
assert.notEqual(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the ghost when adjacent right-side text only shares the first character", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("system license show", "syst");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
const beforeCursor = "host# syst";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = beforeCursor.length;
|
||||
active.getLine = () => ({ translateToString: () => `${beforeCursor}error` });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), true);
|
||||
assert.notEqual(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -47,6 +47,26 @@ function stringCellWidth(s: string): number {
|
||||
return w;
|
||||
}
|
||||
|
||||
function commonPrefixLength(a: string, b: string): number {
|
||||
const max = Math.min(a.length, b.length);
|
||||
let i = 0;
|
||||
while (i < max && a[i] === b[i]) i += 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
function hasVisibleGhostPrefix(ghostText: string, afterCursor: string): boolean {
|
||||
if (!ghostText || !afterCursor) return false;
|
||||
const visibleAfterCursor = afterCursor.trimEnd();
|
||||
const overlap = commonPrefixLength(ghostText, visibleAfterCursor);
|
||||
if (overlap <= 0) return false;
|
||||
if (ghostText.slice(0, overlap).trim().length === 0) return false;
|
||||
return (
|
||||
overlap === ghostText.length ||
|
||||
overlap === visibleAfterCursor.length ||
|
||||
afterCursor[overlap] === " "
|
||||
);
|
||||
}
|
||||
|
||||
export class GhostTextAddon implements IDisposable {
|
||||
private term: XTerm | null = null;
|
||||
private ghostElement: HTMLSpanElement | null = null;
|
||||
@@ -135,9 +155,9 @@ export class GhostTextAddon implements IDisposable {
|
||||
if (this.hintActive) this.updateHintPosition();
|
||||
if (!this.isVisible()) return;
|
||||
// Fail-safe: if the device echoed input we didn't track (some bastion
|
||||
// hosts / network OS, #1013), hide rather than draw the ghost over
|
||||
// already-typed text. Done here (post-echo render) rather than in
|
||||
// show()/adjustToInput so it never fights the keystroke-time path.
|
||||
// hosts / network OS, #1013/#1060), hide rather than draw the ghost
|
||||
// over already-visible text. Done here (post-echo render) rather than
|
||||
// in show()/adjustToInput so it never fights the keystroke-time path.
|
||||
if (this.realLineHasUntrackedInput()) {
|
||||
this.hide();
|
||||
return;
|
||||
@@ -331,7 +351,7 @@ export class GhostTextAddon implements IDisposable {
|
||||
}
|
||||
|
||||
getGhostText(): string {
|
||||
if (!this.currentSuggestion || !this.currentInput) return "";
|
||||
if (!this.currentSuggestion) return "";
|
||||
return this.currentSuggestion.startsWith(this.currentInput)
|
||||
? this.currentSuggestion.substring(this.currentInput.length)
|
||||
: "";
|
||||
@@ -355,19 +375,24 @@ export class GhostTextAddon implements IDisposable {
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the real terminal line has more input than we tracked, so
|
||||
* rendering the ghost would paint over already-typed characters. See
|
||||
* ./ghostTextConsistency and issue #1013. Returns false on hosts/inputs
|
||||
* we can't judge (non-ASCII, echo still catching up), so the ghost only
|
||||
* gets suppressed when corruption is actually imminent.
|
||||
* True when the real terminal line has input we did not track, or already
|
||||
* visible text exactly matches the ghost we are about to paint. See
|
||||
* ./ghostTextConsistency and issues #1013 and #1060. Returns false on
|
||||
* hosts/inputs we can't judge (non-ASCII, echo still catching up), so the
|
||||
* ghost only gets suppressed when corruption is actually imminent.
|
||||
*/
|
||||
private realLineHasUntrackedInput(): boolean {
|
||||
if (!this.term || !this.currentInput) return false;
|
||||
if (!this.term) return false;
|
||||
const buf = this.term.buffer.active;
|
||||
if (typeof buf?.getLine !== "function") return false;
|
||||
const line = buf.getLine(buf.baseY + buf.cursorY);
|
||||
if (!line || typeof line.translateToString !== "function") return false;
|
||||
const beforeCursor = line.translateToString(false).slice(0, buf.cursorX);
|
||||
const lineText = line.translateToString(false);
|
||||
const beforeCursor = lineText.slice(0, buf.cursorX);
|
||||
const afterCursor = lineText.slice(buf.cursorX);
|
||||
const ghostText = this.getGhostText();
|
||||
if (hasVisibleGhostPrefix(ghostText, afterCursor)) return true;
|
||||
if (!this.currentInput) return false;
|
||||
return lineHasUntrackedTrailingInput(this.currentInput, beforeCursor);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user