qt: phase 4 — extract UndoStack into qt/src/undo/

Pulls the static s_undoStack / s_redoStack / s_redoInProgress / kUndoCap
plus pushTabUndo / pushWindowUndo / undoLastClose / redoLastClose out
of MainWindow into a new `undo::` namespace under qt/src/undo/. State
lives file-static in UndoStack.cpp; callers see only push / replay
free functions.

Replay-side hooks are two new public methods on MainWindow:
closeCurrentTabForRedo (Tab redo) and closeForRedo (Window redo).
Both bypass the close-confirm prompt — the user accepted the close
on the original action.

surfaceAt is promoted to public so undo::undoLast can title-tag the
revived tab/window without going through MainWindow internals.

MainWindow.cpp shrinks ~140 lines; MainWindow.h drops the UndoEntry
struct and the static stack declarations.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
ntomsic 2026-05-23 14:42:38 -05:00
parent 4102618fbe
commit 7845751538
5 changed files with 264 additions and 180 deletions

View File

@ -115,6 +115,7 @@ add_executable(ghastty
src/OverlayScrollbar.cpp
src/SearchBar.cpp
src/TabWidget.cpp
src/undo/UndoStack.cpp
src/Util.cpp
src/WindowBlur.cpp
src/XkbTracker.cpp

View File

@ -54,6 +54,7 @@
#include "CommandPalette.h"
#include "GhosttySurface.h"
#include "TabWidget.h"
#include "undo/UndoStack.h"
#include "Util.h"
#include "WindowBlur.h"
@ -442,11 +443,11 @@ void MainWindow::removeSurface(GhosttySurface *surface) {
const int index = m_tabs->indexOf(parent);
// Push to undo so a shell-exited tab close is symmetric with a
// user-initiated tab close (closeTab pushes too). Skip the last
// tab — its closeEvent runs pushWindowUndo and we don't want to
// tab — its closeEvent runs undo::pushWindow and we don't want to
// double-stack. Also skip the quick terminal (which doesn't push
// to either stack by design).
if (index >= 0 && m_tabs->count() > 1 && !m_quickTerminal)
pushTabUndo(index);
undo::pushTab(m_tabs->tabText(index), m_quickTerminal);
if (index >= 0) m_tabs->removeTab(index);
if (parent) parent->deleteLater(); // page; destroys the surface too
// The surface close was already confirmed; don't re-prompt on the
@ -461,9 +462,10 @@ void MainWindow::closeTab(int index) {
QWidget *page = m_tabs->widget(index);
if (!page) return;
// Snapshot the tab's title for undo before we lose the reference.
// pushTabUndo is no-op for the last tab in a window — that close
// ends up triggering pushWindowUndo via closeEvent instead.
if (m_tabs->count() > 1 && !m_quickTerminal) pushTabUndo(index);
// undo::pushTab is no-op for the last tab in a window — that close
// ends up triggering undo::pushWindow via closeEvent instead.
if (m_tabs->count() > 1 && !m_quickTerminal)
undo::pushTab(m_tabs->tabText(index), m_quickTerminal);
const auto inTab = page->findChildren<GhosttySurface *>();
for (GhosttySurface *s : inTab) m_surfaces.removeOne(s);
// If the zoomed surface was in this tab, clear the stash so a later
@ -655,7 +657,11 @@ void MainWindow::closeEvent(QCloseEvent *e) {
// Snapshot for undo. We push the window's full tab list so undo
// restores all of them; closeTab paths skip the per-tab push when
// they reach the last tab so we don't double-stack the same close.
pushWindowUndo();
QStringList titles;
titles.reserve(m_tabs->count());
for (int i = 0; i < m_tabs->count(); ++i)
titles << m_tabs->tabText(i);
undo::pushWindow(titles, geometry(), m_quickTerminal);
e->accept();
}
@ -1444,147 +1450,22 @@ void MainWindow::setCellSize(uint32_t w, uint32_t h) {
setSizeIncrement(0, 0); // back to pixel-precise
}
// Process-wide undo state — see MainWindow.h.
QList<MainWindow::UndoEntry> MainWindow::s_undoStack;
QList<MainWindow::UndoEntry> MainWindow::s_redoStack;
bool MainWindow::s_redoInProgress = false;
void MainWindow::undoLastClose() { undo::undoLast(); }
void MainWindow::redoLastClose() { undo::redoLast(); }
// Snapshot the tab at `index` (its tab text — last-known title) onto
// the undo stack. Called from closeTab / closeTabsByMode / right
// before the tab is removed. No-op while a redo is replaying.
void MainWindow::pushTabUndo(int index) {
if (s_redoInProgress) return;
if (index < 0 || index >= m_tabs->count()) return;
UndoEntry e;
e.kind = UndoEntry::Kind::Tab;
e.pageTitles << m_tabs->tabText(index);
s_undoStack.append(std::move(e));
if (s_undoStack.size() > kUndoCap) s_undoStack.removeFirst();
// A fresh close invalidates any pending redo: the new "future" no
// longer matches what the redo stack would re-close.
s_redoStack.clear();
// Close the active tab without prompting. Called from
// undo::redoLast for a Tab redo: the user already accepted the
// original close, so re-closing carries the same prior consent.
void MainWindow::closeCurrentTabForRedo() {
const int idx = m_tabs->currentIndex();
if (idx >= 0) closeTab(idx);
}
// Snapshot every tab in this window before it goes away. Called from
// closeAllWindows and from closeEvent for the user-driven X. No-op
// while a redo is replaying.
void MainWindow::pushWindowUndo() {
if (s_redoInProgress) return;
if (m_quickTerminal || m_tabs->count() == 0) return;
UndoEntry e;
e.kind = UndoEntry::Kind::Window;
for (int i = 0; i < m_tabs->count(); ++i)
e.pageTitles << m_tabs->tabText(i);
e.geometry = geometry();
s_undoStack.append(std::move(e));
if (s_undoStack.size() > kUndoCap) s_undoStack.removeFirst();
s_redoStack.clear();
}
// Pop the most recent undo entry and revive it. A new tab/window is
// opened that inherits cwd from the active surface (libghostty
// supplies the cwd via inherited_config), and the saved title is
// reapplied as a manual tab-title override so it persists across
// shell prompts.
void MainWindow::undoLastClose() {
if (s_undoStack.isEmpty()) return;
const UndoEntry e = s_undoStack.takeLast();
// The active window picks the new tab's parent surface for cwd
// inheritance. Skip the quick terminal — it doesn't push undo
// entries and isn't a meaningful target. Fall back to the most
// recent regular window in registration order.
auto isUndoTarget = [](MainWindow *w) {
return w && !w->m_quickTerminal;
};
MainWindow *active = qobject_cast<MainWindow *>(qApp->activeWindow());
if (!isUndoTarget(active)) {
active = nullptr;
const QList<MainWindow *> &live = GhosttyApp::instance().windows();
for (int i = live.size() - 1; i >= 0; --i) {
if (isUndoTarget(live.at(i))) {
active = live.at(i);
break;
}
}
}
GhosttySurface *parent = active
? active->surfaceAt(active->m_tabs->currentIndex())
: nullptr;
if (e.kind == UndoEntry::Kind::Tab) {
if (!active) return;
GhosttySurface *s = active->newTab(parent ? parent->surface() : nullptr);
if (s && !e.pageTitles.isEmpty())
active->setTabTitleOverride(s, e.pageTitles.first());
} else {
// Window: open a fresh window, then add additional tabs to match
// the saved tab count. We don't try to recreate the split tree
// — that would require a real session save mechanism.
MainWindow *w = MainWindow::newWindow(parent ? parent->surface() : nullptr);
if (!w) return;
if (e.geometry.isValid()) w->setGeometry(e.geometry);
// Title for the (eventually created) first tab.
if (!e.pageTitles.isEmpty()) {
const QString first = e.pageTitles.first();
QPointer<MainWindow> wp(w);
QTimer::singleShot(0, w, [wp, first]() {
if (!wp) return;
if (auto *s = wp->surfaceAt(0)) wp->setTabTitleOverride(s, first);
});
}
// Additional tabs for the rest of the saved set.
for (int i = 1; i < e.pageTitles.size(); ++i) {
const QString t = e.pageTitles.at(i);
QPointer<MainWindow> wp(w);
QTimer::singleShot(0, w, [wp, t]() {
if (!wp) return;
GhosttySurface *s =
wp->newTab(wp->surfaceAt(0) ? wp->surfaceAt(0)->surface() : nullptr);
if (s) wp->setTabTitleOverride(s, t);
});
}
}
s_redoStack.append(e);
if (s_redoStack.size() > kUndoCap) s_redoStack.removeFirst();
}
// Redo: re-close whatever undo just opened. We don't have a handle on
// the revived widgets so we close the active window's current tab
// (or the active window itself for a Window entry); pragmatic,
// matches what a user normally means by "redo close-tab".
//
// pushTabUndo / pushWindowUndo are no-ops while s_redoInProgress is
// true, so the close paths below don't:
// (a) clear s_redoStack — preserving the rest of the redo chain
// so a sequence of REDOs works.
// (b) push a fresh undo entry — a redo that re-closes shouldn't
// feed itself a new undo or the user can ping-pong undo/redo
// on a single past close indefinitely.
void MainWindow::redoLastClose() {
if (s_redoStack.isEmpty()) return;
UndoEntry e = s_redoStack.takeLast();
MainWindow *active = qobject_cast<MainWindow *>(qApp->activeWindow());
if (!active) {
const QList<MainWindow *> &live = GhosttyApp::instance().windows();
if (!live.isEmpty()) active = live.last();
}
if (!active) {
// No window to act on — restore the entry so the user can retry.
s_redoStack.append(std::move(e));
return;
}
s_redoInProgress = true;
if (e.kind == UndoEntry::Kind::Tab) {
const int idx = active->m_tabs->currentIndex();
if (idx >= 0) active->closeTab(idx);
} else {
active->m_skipCloseConfirm = true;
active->close();
}
s_redoInProgress = false;
// Close the entire window without re-prompting. Called from
// undo::redoLast for a Window redo.
void MainWindow::closeForRedo() {
m_skipCloseConfirm = true;
close();
}
void MainWindow::applyWindowConfig() {

View File

@ -76,17 +76,20 @@ public:
// need to take a dependency on app/GhosttyApp.h.
ghostty_config_t config() const;
// UNDO / REDO close-tab/window. The libghostty actions carry no
// payload — the apprt is responsible for tracking what was closed
// and reviving it. macOS uses NSUndoManager; we keep a small bounded
// stack of "snapshots" per kind. Surfaces themselves can't be
// revived (the child PTY is gone) — undo opens a fresh tab/window
// and reapplies the saved title; the new surface inherits cwd from
// the active surface (matching macOS, which also spawns a fresh
// shell rather than re-attaching).
// UNDO / REDO close-tab/window action handlers — thin wrappers that
// drive undo::undoLast / undo::redoLast. The undo state lives in
// qt/src/undo/UndoStack; the comment there explains the lifetime
// and replay model.
static void undoLastClose();
static void redoLastClose();
// Replay-side hooks used by undo::redoLast. closeCurrentTabForRedo
// closes whichever tab is currently active (Tab redo); closeForRedo
// closes the entire window (Window redo). Both bypass the
// close-confirm prompt — the user already accepted the close once.
void closeCurrentTabForRedo();
void closeForRedo();
// PRESENT_TERMINAL: bring this window to front and focus the surface.
void presentTerminal(GhosttySurface *surface);
// GOTO_WINDOW: cycle to the previous/next window in registration order.
@ -154,6 +157,9 @@ public:
// First surface in the currently-visible tab, or nullptr. Used by
// PROMPT_TITLE app-target promotion.
GhosttySurface *currentSurface() const;
// First surface in the tab at `index`, or nullptr. Used by
// undo::undoLast to title-tag the revived tab/window.
GhosttySurface *surfaceAt(int index) const;
// Default size cached on INITIAL_SIZE for RESET_WINDOW_SIZE.
QSize defaultWindowSize() const { return m_defaultWindowSize; }
void setDefaultWindowSize(QSize s) { m_defaultWindowSize = s; }
@ -194,7 +200,6 @@ private:
void detachTab(int index);
// Move `page` (a tab and its surfaces) from `src` into this window.
void adoptTab(MainWindow *src, QWidget *page);
GhosttySurface *surfaceAt(int index) const;
int tabIndexForSurface(GhosttySurface *surface) const;
QList<GhosttySurface *> surfacesInTab(int index) const;
@ -254,33 +259,8 @@ private:
// GhosttyApp::instance(). MainWindow's config() / needsPremultiply()
// accessors forward to it.
// Snapshot of a closed tab or window for undo/redo. `pageTitles`
// holds each tab's last-known title (window snapshots have N tabs;
// tab snapshots have one). `geometry` is unused for tab snapshots.
// `kind` distinguishes the two so REDO can reclose the right thing.
struct UndoEntry {
enum class Kind { Tab, Window } kind = Kind::Tab;
QStringList pageTitles;
QRect geometry;
};
// Bounded undo/redo stacks (tail = most recent). Each tab/window
// close pushes an entry, capped at kUndoCap; opening a new
// tab/window via undo pushes onto the redo stack. While
// `s_redoInProgress` is true, the close paths that normally
// mutate these stacks (pushTabUndo / pushWindowUndo) become
// no-ops — a redo is replaying a previous close and shouldn't
// also feed itself a fresh undo entry that the user will then
// unwind into a loop.
static QList<UndoEntry> s_undoStack;
static QList<UndoEntry> s_redoStack;
static bool s_redoInProgress;
static constexpr int kUndoCap = 16;
// Push a snapshot for the tab at `index` onto s_undoStack and
// clear the redo stack (a new close invalidates a forward redo).
void pushTabUndo(int index);
// Push a snapshot of every tab in this window onto s_undoStack as a
// single Window entry; called from closeAllWindows / closeEvent.
void pushWindowUndo();
// The undo/redo stacks live in qt/src/undo/UndoStack — see comment
// there for the lifecycle and replay semantics.
// Wakeup tick coalescing lives on GhosttyApp::m_tickPending.

167
qt/src/undo/UndoStack.cpp Normal file
View File

@ -0,0 +1,167 @@
#include "UndoStack.h"
#include <QApplication>
#include <QList>
#include <QPointer>
#include <QTimer>
#include "../app/GhosttyApp.h"
#include "../GhosttySurface.h"
#include "../MainWindow.h"
namespace undo {
namespace {
// Bounded undo / redo stacks (tail = most recent). A push past the cap
// drops the oldest entry. The redo stack is cleared by every fresh
// close — a new "future" no longer matches what redo would re-close.
constexpr int kCap = 16;
QList<Entry> &undoStack() {
static QList<Entry> s;
return s;
}
QList<Entry> &redoStack() {
static QList<Entry> s;
return s;
}
// True while undo::redoLast is replaying. push* is gated on this so a
// redo that re-closes doesn't:
// (a) clear the redo stack (the rest of the redo chain stays
// playable), and
// (b) push a fresh undo entry (otherwise the user can ping-pong
// undo/redo on a single past close indefinitely).
bool g_redoInProgress = false;
// Pick the active window for an undo target. Skips the quick terminal
// (it doesn't push undo entries, so re-opening into it isn't
// meaningful). Falls back to the most recent registered regular
// window. Returns nullptr if no regular window exists.
MainWindow *activeUndoTarget() {
auto isUndoTarget = [](MainWindow *w) {
return w && !w->isQuickTerminal();
};
MainWindow *active = qobject_cast<MainWindow *>(qApp->activeWindow());
if (isUndoTarget(active)) return active;
const QList<MainWindow *> &live = GhosttyApp::instance().windows();
for (int i = live.size() - 1; i >= 0; --i) {
if (isUndoTarget(live.at(i))) return live.at(i);
}
return nullptr;
}
// Pick the window the user is currently looking at for a redo. Unlike
// undo, redo doesn't filter the quick terminal — REDO without an
// active regular window leaves the entry in place (caller restores).
MainWindow *activeRedoTarget() {
MainWindow *active = qobject_cast<MainWindow *>(qApp->activeWindow());
if (active) return active;
const QList<MainWindow *> &live = GhosttyApp::instance().windows();
return live.isEmpty() ? nullptr : live.last();
}
void pushUndo(Entry e) {
QList<Entry> &s = undoStack();
s.append(std::move(e));
if (s.size() > kCap) s.removeFirst();
// A fresh close invalidates any pending redo: the future the redo
// stack would replay no longer matches the world.
redoStack().clear();
}
} // namespace
void pushTab(const QString &tabText, bool quickTerminal) {
if (g_redoInProgress) return;
if (quickTerminal) return;
Entry e;
e.kind = Entry::Kind::Tab;
e.pageTitles << tabText;
pushUndo(std::move(e));
}
void pushWindow(const QStringList &tabTitles, const QRect &geometry,
bool quickTerminal) {
if (g_redoInProgress) return;
if (quickTerminal || tabTitles.isEmpty()) return;
Entry e;
e.kind = Entry::Kind::Window;
e.pageTitles = tabTitles;
e.geometry = geometry;
pushUndo(std::move(e));
}
void undoLast() {
QList<Entry> &s = undoStack();
if (s.isEmpty()) return;
const Entry e = s.takeLast();
MainWindow *active = activeUndoTarget();
GhosttySurface *parent = active ? active->currentSurface() : nullptr;
if (e.kind == Entry::Kind::Tab) {
if (!active) return; // dropping the entry: no target to revive into
GhosttySurface *fresh =
active->newTab(parent ? parent->surface() : nullptr);
if (fresh && !e.pageTitles.isEmpty())
active->setTabTitleOverride(fresh, e.pageTitles.first());
} else {
// Window: spawn a fresh window, then queue extra tabs to match
// the saved tab count. We don't try to recreate the split tree
// — that would need a real session save mechanism.
MainWindow *w =
MainWindow::newWindow(parent ? parent->surface() : nullptr);
if (!w) return;
if (e.geometry.isValid()) w->setGeometry(e.geometry);
if (!e.pageTitles.isEmpty()) {
const QString first = e.pageTitles.first();
QPointer<MainWindow> wp(w);
QTimer::singleShot(0, w, [wp, first]() {
if (!wp) return;
if (auto *fresh = wp->surfaceAt(0))
wp->setTabTitleOverride(fresh, first);
});
}
for (int i = 1; i < e.pageTitles.size(); ++i) {
const QString t = e.pageTitles.at(i);
QPointer<MainWindow> wp(w);
QTimer::singleShot(0, w, [wp, t]() {
if (!wp) return;
GhosttySurface *first = wp->surfaceAt(0);
GhosttySurface *fresh =
wp->newTab(first ? first->surface() : nullptr);
if (fresh) wp->setTabTitleOverride(fresh, t);
});
}
}
QList<Entry> &r = redoStack();
r.append(e);
if (r.size() > kCap) r.removeFirst();
}
void redoLast() {
QList<Entry> &r = redoStack();
if (r.isEmpty()) return;
Entry e = r.takeLast();
MainWindow *active = activeRedoTarget();
if (!active) {
// No window to act on — restore the entry so the user can retry.
r.append(std::move(e));
return;
}
g_redoInProgress = true;
if (e.kind == Entry::Kind::Tab) {
active->closeCurrentTabForRedo();
} else {
active->closeForRedo();
}
g_redoInProgress = false;
}
} // namespace undo

55
qt/src/undo/UndoStack.h Normal file
View File

@ -0,0 +1,55 @@
#pragma once
#include <QList>
#include <QRect>
#include <QString>
#include <QStringList>
class MainWindow;
// Process-wide undo / redo of closed tabs and windows.
//
// libghostty's UNDO / REDO actions carry no payload — the apprt
// remembers what was closed and revives it. Surfaces themselves can't
// be revived (the child PTY is gone), so undo opens a fresh tab/window
// and reapplies the saved title; the new surface inherits cwd from
// the active surface, matching macOS (which also spawns a fresh
// shell rather than re-attaching).
//
// State lives in this file's anonymous namespace; callers see only
// the four push / replay functions. push* are no-ops while a redo is
// replaying so the redo path doesn't feed itself.
namespace undo {
// Snapshot of one closed tab or window. Window snapshots carry every
// tab's last-known title and the window's geometry; tab snapshots
// carry one title and an unused geometry.
struct Entry {
enum class Kind { Tab, Window } kind = Kind::Tab;
QStringList pageTitles;
QRect geometry;
};
// Snapshot the tab at `index` — single title — onto the undo stack.
// `tabText` is the tab's last-known display text; `quickTerminal` is
// the window's quick-terminal flag (quick-terminal tabs are excluded
// from the stack, mirroring the prior MainWindow behavior).
void pushTab(const QString &tabText, bool quickTerminal);
// Snapshot every tab's title plus the window's geometry as a single
// Window entry. Excluded for the quick terminal and for empty
// windows.
void pushWindow(const QStringList &tabTitles, const QRect &geometry,
bool quickTerminal);
// Pop the most recent entry and revive it: open a fresh tab or
// window, set the saved title(s) as a manual override, and push the
// entry onto the redo stack. No-op if the stack is empty.
void undoLast();
// Pop the most recent redo entry and re-close the active window's
// current tab (Tab entries) or the active window itself (Window
// entries). No-op if the stack is empty or no active window exists.
void redoLast();
} // namespace undo