feat: add explicit Neovim panel control modes
This commit is contained in:
+159
-30
@@ -201,6 +201,7 @@ type NvimPanelSummary = {
|
||||
detail?: string;
|
||||
syncMode: boolean;
|
||||
syncReady: boolean;
|
||||
controlMode: boolean;
|
||||
controlEnabled: boolean;
|
||||
};
|
||||
|
||||
@@ -258,6 +259,14 @@ function Topbar({
|
||||
setScale,
|
||||
nvimVisible,
|
||||
setNvimVisible,
|
||||
nvimSyncMode,
|
||||
setNvimSyncMode,
|
||||
nvimControlMode,
|
||||
setNvimControlMode,
|
||||
nvimExpanded,
|
||||
setNvimExpanded,
|
||||
nvimFit,
|
||||
setNvimFit,
|
||||
nvimSummary,
|
||||
}: {
|
||||
toc: CardModel["toc"];
|
||||
@@ -265,6 +274,14 @@ function Topbar({
|
||||
setScale: (value: number) => void;
|
||||
nvimVisible: boolean;
|
||||
setNvimVisible: (value: boolean) => void;
|
||||
nvimSyncMode: boolean;
|
||||
setNvimSyncMode: (value: boolean) => void;
|
||||
nvimControlMode: boolean;
|
||||
setNvimControlMode: (value: boolean) => void;
|
||||
nvimExpanded: boolean;
|
||||
setNvimExpanded: (value: boolean) => void;
|
||||
nvimFit: boolean;
|
||||
setNvimFit: (value: boolean) => void;
|
||||
nvimSummary: NvimPanelSummary;
|
||||
}) {
|
||||
return (
|
||||
@@ -286,7 +303,7 @@ function Topbar({
|
||||
aria-pressed={nvimVisible}
|
||||
onClick={() => setNvimVisible(!nvimVisible)}
|
||||
>
|
||||
{nvimVisible ? "F1 · Ukryj Neovim" : "F1 · Pokaż Neovim"}
|
||||
{nvimVisible ? "Alt+1 · Ukryj Neovim" : "Alt+1 · Pokaż Neovim"}
|
||||
</button>
|
||||
{nvimVisible && (
|
||||
<>
|
||||
@@ -297,14 +314,40 @@ function Topbar({
|
||||
<strong>{nvimSummary.instance ?? "Neovim MCP"}</strong>
|
||||
{nvimSummary.detail && <small>{nvimSummary.detail}</small>}
|
||||
</span>
|
||||
<span className={`nvim-control-indicator${nvimSummary.syncMode ? " is-active" : ""}${nvimSummary.syncReady ? " is-engaged" : ""}`}>
|
||||
{nvimSummary.syncMode ? "F2 · SYNC ON" : "F2 · SYNC OFF"}
|
||||
</span>
|
||||
<span className={`nvim-work-indicator${nvimSummary.controlEnabled ? " is-active" : ""}`}>
|
||||
{nvimSummary.controlEnabled ? "STEROWANIE NVIM" : "NAJEDŹ · PRACUJ"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`nvim-control-indicator${nvimSummary.syncMode ? " is-active" : ""}${nvimSummary.syncReady ? " is-engaged" : ""}`}
|
||||
aria-pressed={nvimSyncMode}
|
||||
onClick={() => setNvimSyncMode(!nvimSyncMode)}
|
||||
>
|
||||
{nvimSyncMode ? "Alt+2 · SYNC ON" : "Alt+2 · SYNC OFF"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`nvim-work-indicator${nvimControlMode ? " is-active" : ""}${nvimSummary.controlEnabled ? " is-engaged" : ""}`}
|
||||
aria-pressed={nvimControlMode}
|
||||
onClick={() => setNvimControlMode(!nvimControlMode)}
|
||||
>
|
||||
{nvimControlMode ? "Alt+3 · STEROWANIE ON" : "Alt+3 · STEROWANIE OFF"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`nvim-height-toggle${nvimExpanded ? " is-active" : ""}`}
|
||||
aria-pressed={nvimExpanded}
|
||||
onClick={() => setNvimExpanded(!nvimExpanded)}
|
||||
>
|
||||
{nvimExpanded ? "Alt+4 · NORMALNA WYSOKOŚĆ" : "Alt+4 · WYSOKI PANEL"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`nvim-height-toggle${nvimFit ? " is-active" : ""}`}
|
||||
aria-pressed={nvimFit}
|
||||
onClick={() => setNvimFit(!nvimFit)}
|
||||
>
|
||||
{nvimFit ? "Alt+5 · AUTO-FIT OFF" : "Alt+5 · POKAŻ CAŁY NVIM"}
|
||||
</button>
|
||||
<span className="nvim-shortcut-strip" aria-label="Skróty panelu Neovima">
|
||||
<kbd>F3</kbd> pełny · <kbd>F4</kbd> odśwież · <kbd>F12</kbd> skróty
|
||||
<kbd>Alt+W</kbd> okna · <kbd>F12</kbd> skróty
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -822,11 +865,17 @@ function keyForNvim(event: React.KeyboardEvent<HTMLElement>) {
|
||||
function NeovimPanel({
|
||||
visible,
|
||||
syncMode,
|
||||
controlMode,
|
||||
expanded,
|
||||
fit,
|
||||
scale,
|
||||
onSummaryChange,
|
||||
}: {
|
||||
visible: boolean;
|
||||
syncMode: boolean;
|
||||
controlMode: boolean;
|
||||
expanded: boolean;
|
||||
fit: boolean;
|
||||
scale: number;
|
||||
onSummaryChange: (summary: NvimPanelSummary) => void;
|
||||
}) {
|
||||
@@ -853,7 +902,7 @@ function NeovimPanel({
|
||||
const [checkpoint, setCheckpoint] = useState<CheckpointStatus>({ state: "idle" });
|
||||
const checkpointBusy = checkpoint.state === "replaying" || checkpoint.state === "verifying";
|
||||
const syncReady = syncMode && status.state === "connected" && !checkpointBusy;
|
||||
const controlEnabled = pointerInside && status.state === "connected" && !checkpointBusy;
|
||||
const controlEnabled = controlMode && pointerInside && status.state === "connected" && !checkpointBusy;
|
||||
|
||||
const redraw = () => {
|
||||
if (canvasRef.current) {
|
||||
@@ -1037,8 +1086,9 @@ function NeovimPanel({
|
||||
}, [panelHeightVh]);
|
||||
|
||||
useEffect(() => {
|
||||
redraw();
|
||||
}, [scale]);
|
||||
const frame = window.requestAnimationFrame(redraw);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [expanded, fit, scale]);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
@@ -1070,19 +1120,20 @@ function NeovimPanel({
|
||||
].filter(Boolean).join(" · "),
|
||||
syncMode,
|
||||
syncReady,
|
||||
controlMode,
|
||||
controlEnabled,
|
||||
});
|
||||
}, [activeStep, checkpoint, controlEnabled, onSummaryChange, status, syncMode, syncReady]);
|
||||
}, [activeStep, checkpoint, controlEnabled, controlMode, onSummaryChange, status, syncMode, syncReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!controlEnabled) return;
|
||||
const protectBrowserShortcuts = (event: KeyboardEvent) => {
|
||||
const socket = websocketRef.current;
|
||||
if (socket?.readyState !== WebSocket.OPEN) return;
|
||||
if (event.ctrlKey && !event.altKey && !event.metaKey && event.key.toLowerCase() === "w") {
|
||||
if (event.altKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === "w") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (!event.repeat) socket.send(JSON.stringify({ type: "input", keys: "<C-W>" }));
|
||||
if (!event.repeat) socket.send(JSON.stringify({ type: "input", keys: "<M-w>" }));
|
||||
return;
|
||||
}
|
||||
const directions: Record<string, string> = {
|
||||
@@ -1095,7 +1146,7 @@ function NeovimPanel({
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (!event.repeat) {
|
||||
socket.send(JSON.stringify({ type: "input", keys: `<C-W>${directions[event.key]}` }));
|
||||
socket.send(JSON.stringify({ type: "input", keys: `<M-w>${directions[event.key]}` }));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1200,7 +1251,7 @@ function NeovimPanel({
|
||||
<div
|
||||
ref={screenRef}
|
||||
className="nvim-screen-scroll"
|
||||
style={{ height: `${panelHeightVh}vh` }}
|
||||
style={fit ? undefined : { height: `${expanded ? 90 : panelHeightVh}vh` }}
|
||||
data-sync={syncMode ? "on" : "off"}
|
||||
data-control={controlEnabled ? "true" : "false"}
|
||||
tabIndex={0}
|
||||
@@ -1224,7 +1275,7 @@ function NeovimPanel({
|
||||
>
|
||||
<div
|
||||
className="nvim-screen-stage"
|
||||
style={{ height: `${panelHeightVh / Math.max(scale, 0.25)}vh` }}
|
||||
style={fit ? undefined : { height: `${(expanded ? 90 : panelHeightVh) / Math.max(scale, 0.25)}vh` }}
|
||||
>
|
||||
<div className="nvim-screen-surface">
|
||||
<div className="nvim-screen-frame">
|
||||
@@ -2711,11 +2762,14 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void })
|
||||
["Ctrl + Shift + ← / →", "poprzedni / następny unikalny SNAPSHOT"],
|
||||
["Alt + ← / →", "poprzedni / następny BLOCK"],
|
||||
["Ctrl + ← / →", "poprzedni / następny TASK"],
|
||||
["F1", "pokaż lub ukryj panel Neovima"],
|
||||
["F2", "przełącz SYNC OFF / SYNC ON"],
|
||||
["F3", "włącz lub wyłącz pełny ekran Neovima bez suwaka"],
|
||||
["F4", "odśwież layout i ekran Neovima"],
|
||||
["Najedź na Neovim", "przekaż klawiaturę i mysz do aktywnej sesji"],
|
||||
["Alt + 1", "pokaż lub ukryj panel Neovima"],
|
||||
["Alt + 2", "przełącz SYNC OFF / SYNC ON"],
|
||||
["Alt + 3", "uzbrój lub rozbrój sterowanie Neovimem"],
|
||||
["Alt + 4", "wysoki panel 90% / zapisana wysokość normalna"],
|
||||
["Alt + 5", "dopasuj cały ekran Neovima; wyłącz ręczny suwak"],
|
||||
["Alt + 3, potem najedź", "przekaż klawiaturę i mysz do aktywnej sesji"],
|
||||
["Alt + W", "prefiks poleceń okien Neovima (<C-W>)"],
|
||||
["Alt + strzałka", "przejdź do sąsiedniego okna Neovima"],
|
||||
["Zatwierdź krok", "zapisz bieżący step i timestamp do raportu"],
|
||||
["F12", "pokaż lub ukryj ten skorowidz"],
|
||||
["Esc", "zamknij skorowidz"],
|
||||
@@ -2764,11 +2818,16 @@ function App({ model }: { model: CardModel }) {
|
||||
const [nvimSyncMode, setNvimSyncModeState] = useState(
|
||||
() => localStorage.getItem("stem-card-nvim-sync-mode") === "true",
|
||||
);
|
||||
const [nvimFullscreen, setNvimFullscreen] = useState(false);
|
||||
const [nvimControlMode, setNvimControlModeState] = useState(
|
||||
() => localStorage.getItem("stem-card-nvim-control-mode") === "true",
|
||||
);
|
||||
const [nvimExpanded, setNvimExpandedState] = useState(false);
|
||||
const [nvimFit, setNvimFitState] = useState(false);
|
||||
const [nvimSummary, setNvimSummary] = useState<NvimPanelSummary>({
|
||||
state: "hidden",
|
||||
syncMode: false,
|
||||
syncReady: false,
|
||||
controlMode: false,
|
||||
controlEnabled: false,
|
||||
});
|
||||
const [progress, setProgress] = useState<Progress | null>(null);
|
||||
@@ -2787,8 +2846,41 @@ function App({ model }: { model: CardModel }) {
|
||||
}, [nvimSyncMode]);
|
||||
const setNvimVisible = (value: boolean) => {
|
||||
setNvimVisibleState(value);
|
||||
if (!value) setNvimExpandedState(false);
|
||||
localStorage.setItem("stem-card-nvim-visible", String(value));
|
||||
};
|
||||
const setNvimControlMode = (value: boolean) => {
|
||||
setNvimControlModeState(value);
|
||||
localStorage.setItem("stem-card-nvim-control-mode", String(value));
|
||||
};
|
||||
const setNvimSyncMode = (value: boolean) => {
|
||||
setNvimSyncModeState(value);
|
||||
localStorage.setItem("stem-card-nvim-sync-mode", String(value));
|
||||
};
|
||||
const setNvimExpanded = (value: boolean) => {
|
||||
if (value) setNvimVisible(true);
|
||||
if (value) setNvimFitState(false);
|
||||
setNvimExpandedState(value);
|
||||
};
|
||||
const setNvimFit = (value: boolean) => {
|
||||
if (value) {
|
||||
setNvimVisible(true);
|
||||
setNvimExpandedState(false);
|
||||
}
|
||||
setNvimFitState(value);
|
||||
};
|
||||
useEffect(() => {
|
||||
let second = 0;
|
||||
const first = window.requestAnimationFrame(() => {
|
||||
second = window.requestAnimationFrame(() => {
|
||||
window.dispatchEvent(new Event("stem:nvim-refresh"));
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
window.cancelAnimationFrame(first);
|
||||
window.cancelAnimationFrame(second);
|
||||
};
|
||||
}, [nvimExpanded, nvimFit]);
|
||||
useEffect(() => {
|
||||
fetch("/api/progress")
|
||||
.then((response) =>
|
||||
@@ -2806,17 +2898,21 @@ function App({ model }: { model: CardModel }) {
|
||||
useEffect(() => {
|
||||
const navigate = (event: KeyboardEvent) => {
|
||||
if (event.repeat) return;
|
||||
if (event.key === "F1") {
|
||||
const browserCommand = event.altKey && !event.ctrlKey && !event.metaKey
|
||||
? event.code
|
||||
: "";
|
||||
if (browserCommand === "Digit1") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
setNvimVisibleState(current => {
|
||||
const next = !current;
|
||||
if (!next) setNvimExpandedState(false);
|
||||
localStorage.setItem("stem-card-nvim-visible", String(next));
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.key === "F2") {
|
||||
if (browserCommand === "Digit2") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
setNvimSyncModeState(current => {
|
||||
@@ -2826,18 +2922,40 @@ function App({ model }: { model: CardModel }) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.key === "F3") {
|
||||
if (browserCommand === "Digit3") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
setNvimVisibleState(true);
|
||||
localStorage.setItem("stem-card-nvim-visible", "true");
|
||||
setNvimFullscreen(current => !current);
|
||||
setNvimControlModeState(current => {
|
||||
const next = !current;
|
||||
localStorage.setItem("stem-card-nvim-control-mode", String(next));
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.key === "F4") {
|
||||
if (browserCommand === "Digit4") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
window.dispatchEvent(new Event("stem:nvim-refresh"));
|
||||
setNvimVisibleState(true);
|
||||
localStorage.setItem("stem-card-nvim-visible", "true");
|
||||
setNvimExpandedState(current => {
|
||||
const next = !current;
|
||||
if (next) setNvimFitState(false);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (browserCommand === "Digit5") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
setNvimVisibleState(true);
|
||||
localStorage.setItem("stem-card-nvim-visible", "true");
|
||||
setNvimFitState(current => {
|
||||
const next = !current;
|
||||
if (next) setNvimExpandedState(false);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.key === "F12") {
|
||||
@@ -2925,7 +3043,7 @@ function App({ model }: { model: CardModel }) {
|
||||
<>
|
||||
<div
|
||||
ref={viewerChromeRef}
|
||||
className={`viewer-chrome${nvimFullscreen ? " is-nvim-fullscreen" : ""}`}
|
||||
className={`viewer-chrome${nvimExpanded ? " is-nvim-expanded" : ""}${nvimFit ? " is-nvim-fit" : ""}`}
|
||||
>
|
||||
<Topbar
|
||||
toc={model.toc}
|
||||
@@ -2933,11 +3051,22 @@ function App({ model }: { model: CardModel }) {
|
||||
setScale={setScale}
|
||||
nvimVisible={nvimVisible}
|
||||
setNvimVisible={setNvimVisible}
|
||||
nvimSyncMode={nvimSyncMode}
|
||||
setNvimSyncMode={setNvimSyncMode}
|
||||
nvimControlMode={nvimControlMode}
|
||||
setNvimControlMode={setNvimControlMode}
|
||||
nvimExpanded={nvimExpanded}
|
||||
setNvimExpanded={setNvimExpanded}
|
||||
nvimFit={nvimFit}
|
||||
setNvimFit={setNvimFit}
|
||||
nvimSummary={nvimSummary}
|
||||
/>
|
||||
<NeovimPanel
|
||||
visible={nvimVisible}
|
||||
syncMode={nvimSyncMode}
|
||||
controlMode={nvimControlMode}
|
||||
expanded={nvimExpanded}
|
||||
fit={nvimFit}
|
||||
scale={scale}
|
||||
onSummaryChange={setNvimSummary}
|
||||
/>
|
||||
|
||||
+54
-12
@@ -8,39 +8,59 @@
|
||||
position: relative;
|
||||
top: auto;
|
||||
}
|
||||
.viewer-chrome.is-nvim-fullscreen {
|
||||
position: fixed;
|
||||
z-index: 300;
|
||||
inset: 0;
|
||||
.viewer-chrome.is-nvim-fit {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #11171a;
|
||||
}
|
||||
.viewer-chrome.is-nvim-fullscreen .topbar {
|
||||
.viewer-chrome.is-nvim-fit .topbar {
|
||||
flex: none;
|
||||
}
|
||||
.viewer-chrome.is-nvim-fullscreen .nvim-panel {
|
||||
.viewer-chrome.is-nvim-fit .nvim-panel {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
.viewer-chrome.is-nvim-fullscreen .nvim-screen-scroll {
|
||||
.viewer-chrome.is-nvim-fit .nvim-screen-scroll {
|
||||
min-height: 0;
|
||||
height: auto !important;
|
||||
flex: 1;
|
||||
}
|
||||
.viewer-chrome.is-nvim-fullscreen .nvim-resize-handle {
|
||||
.viewer-chrome.is-nvim-fit .nvim-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
.viewer-chrome.is-nvim-fullscreen .nvim-screen-content {
|
||||
overflow-y: hidden;
|
||||
.viewer-chrome.is-nvim-fit .nvim-screen-stage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
zoom: 1;
|
||||
}
|
||||
.viewer-chrome.is-nvim-fit .nvim-screen-content {
|
||||
right: 1mm;
|
||||
overflow: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.viewer-chrome.is-nvim-fullscreen .nvim-screen-content::-webkit-scrollbar {
|
||||
.viewer-chrome.is-nvim-fit .nvim-screen-grid {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.viewer-chrome.is-nvim-fit .nvim-screen {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.viewer-chrome.is-nvim-fit .nvim-screen-content::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.topbar-nvim-section {
|
||||
@@ -132,6 +152,7 @@
|
||||
font: 700 10px/1.2 ui-monospace, monospace;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nvim-control-indicator.is-active {
|
||||
border-color: #3cb3e2;
|
||||
@@ -151,12 +172,33 @@
|
||||
font: 700 10px/1.2 ui-monospace, monospace;
|
||||
letter-spacing: .03em;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nvim-work-indicator.is-active {
|
||||
border-color: #cf3d31;
|
||||
background: #fff0ee;
|
||||
color: #9f241b;
|
||||
}
|
||||
.nvim-work-indicator.is-engaged {
|
||||
box-shadow: inset 0 0 0 1px #cf3d31;
|
||||
}
|
||||
.nvim-height-toggle {
|
||||
flex: none;
|
||||
border: 1px solid #75848b;
|
||||
border-radius: 3px;
|
||||
background: #f4f6f7;
|
||||
color: #425159;
|
||||
padding: 3px 8px;
|
||||
font: 700 10px/1.2 ui-monospace, monospace;
|
||||
letter-spacing: .03em;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nvim-height-toggle.is-active {
|
||||
border-color: #267697;
|
||||
background: #eaf6fb;
|
||||
color: #174d64;
|
||||
}
|
||||
.nvim-shortcut-strip {
|
||||
flex: none;
|
||||
color: #536168;
|
||||
|
||||
Reference in New Issue
Block a user