Compare commits
17 Commits
08fcb93e01
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b7adad41f7 | |||
| 95eb5ac9bd | |||
| 2c4b89f8f7 | |||
| cff4fba81c | |||
| 5a50ce62f3 | |||
| 27b7ba6904 | |||
| 7b3a370371 | |||
| 4e2ed7f99f | |||
| b8a4f52609 | |||
| 7367b2f778 | |||
| a6a700b2c0 | |||
| 47b2daa782 | |||
| 8bd8170270 | |||
| c922968376 | |||
| 64263ee3fb | |||
| f621366b0c | |||
| 77fb5e7e11 |
27
README.md
27
README.md
@@ -78,6 +78,9 @@ After that, a bare `jprototerm` connects to the daemon and opens a window in the
|
||||
directory. If no daemon is running, `jprototerm` falls back to a standalone in-process window
|
||||
(today's behavior), so it always works.
|
||||
|
||||
For development testing, use `jprototerm --standalone` to skip the daemon even when one is
|
||||
running.
|
||||
|
||||
To start the daemon automatically with your graphical session, enable the bundled **user**
|
||||
service (it's a user service, not a system one, because X11 needs a display — which only
|
||||
exists after you log in):
|
||||
@@ -146,6 +149,14 @@ enabled = true
|
||||
[scrollback]
|
||||
editor_command = "vi {file}"
|
||||
|
||||
[worktree]
|
||||
relative_worktree_path = "./.worktrees"
|
||||
split_regex = ","
|
||||
# One of: "none", "cd", "create_panes", "create_panes_floating".
|
||||
post_create_action = "none"
|
||||
commands = []
|
||||
sync_panes = false
|
||||
|
||||
[env.override]
|
||||
ZELLIJ_SESSION_NAME = ""
|
||||
|
||||
@@ -163,6 +174,10 @@ previous_tab = "ALT+SHIFT+H"
|
||||
next_tab = "ALT+SHIFT+L"
|
||||
open_font_selector = "ALT+T"
|
||||
open_scrollback = "ALT+S"
|
||||
create_worktree = "ALT+W"
|
||||
pane_sync_toggle = "ALT+Y"
|
||||
pane_sync_select = "SPACE"
|
||||
paste = "CTRL+SHIFT+V"
|
||||
```
|
||||
|
||||
## Defaults
|
||||
@@ -178,6 +193,18 @@ open_scrollback = "ALT+S"
|
||||
- `Alt+Shift+h` / `Alt+Shift+l`: previous / next tab
|
||||
- `Alt+t`: open the font selector
|
||||
- `Alt+s`: open the active pane scrollback in `$EDITOR`
|
||||
- `Alt+w`: edit one or more worktree names, split by `worktree.split_regex`, then run
|
||||
`git worktree add <relative_worktree_path>/<name>` for each name from the previously focused
|
||||
pane's working directory. `worktree.post_create_action` can then do nothing with `none`, `cd` the
|
||||
previously active pane to the last created worktree, create one tiled pane per worktree with
|
||||
`create_panes`, or create one floating pane per worktree with `create_panes_floating`.
|
||||
`worktree.commands`, for example `["npm install", "git status"]`, can run commands in created
|
||||
panes; commands are assigned in order and repeat when fewer commands than panes are created.
|
||||
`worktree.sync_panes = true` syncs those created panes after the configured commands are sent.
|
||||
- `Alt+y`: enter pane-sync selection mode, commit the selection, or stop an active pane sync
|
||||
- `Space`: toggle the focused pane in the sync set while pane-sync selection mode is active
|
||||
- Once committed, input typed or pasted into any synced pane is mirrored to the other synced panes
|
||||
- `Ctrl+Shift+v`: paste
|
||||
- Font default: `JetBrainsMono Nerd Font`
|
||||
- Kitty graphics protocol parsing is enabled by default
|
||||
|
||||
|
||||
@@ -19,6 +19,14 @@ enabled = true
|
||||
[scrollback]
|
||||
editor_command = "vi {file}"
|
||||
|
||||
[worktree]
|
||||
relative_worktree_path = "./.worktrees"
|
||||
split_regex = ","
|
||||
# One of: "none", "cd", "create_panes", "create_panes_floating".
|
||||
post_create_action = "none"
|
||||
commands = []
|
||||
sync_panes = false
|
||||
|
||||
[env.override]
|
||||
ZELLIJ_SESSION_NAME = ""
|
||||
|
||||
@@ -28,9 +36,16 @@ navigate_down = "ALT+J"
|
||||
navigate_up = "ALT+K"
|
||||
navigate_right = "ALT+L"
|
||||
toggle_floating = "ALT+F"
|
||||
new_floating = "ALT+SHIFT+F"
|
||||
new_pane = "ALT+N"
|
||||
next_floating = "ALT+F12"
|
||||
promote_floating = "ALT+P"
|
||||
close_pane = "ALT+X"
|
||||
new_tab = "ALT+A"
|
||||
previous_tab = "ALT+SHIFT+H"
|
||||
next_tab = "ALT+SHIFT+L"
|
||||
open_font_selector = "ALT+T"
|
||||
open_scrollback = "ALT+S"
|
||||
create_worktree = "ALT+W"
|
||||
pane_sync_toggle = "ALT+Y"
|
||||
pane_sync_select = "SPACE"
|
||||
paste = "CTRL+SHIFT+V"
|
||||
|
||||
@@ -29,6 +29,11 @@ public record AppConfig(
|
||||
double windowHeight,
|
||||
boolean kittyGraphics,
|
||||
String scrollbackEditorCommand,
|
||||
String worktreeRelativePath,
|
||||
String worktreeSplitRegex,
|
||||
String worktreePostCreateAction,
|
||||
List<String> worktreeCommands,
|
||||
boolean worktreeSyncPanes,
|
||||
String closeSignal,
|
||||
Map<String, String> envOverride,
|
||||
Map<String, KeyBinding> keybindings
|
||||
@@ -48,6 +53,9 @@ public record AppConfig(
|
||||
"next_tab",
|
||||
"open_font_selector",
|
||||
"open_scrollback",
|
||||
"create_worktree",
|
||||
"pane_sync_toggle",
|
||||
"pane_sync_select",
|
||||
"paste"
|
||||
);
|
||||
|
||||
@@ -72,6 +80,11 @@ public record AppConfig(
|
||||
doubleValue(document, "window.height", defaults.windowHeight),
|
||||
booleanValue(document, "kitty_graphics.enabled", defaults.kittyGraphics),
|
||||
stringValue(document, "scrollback.editor_command", defaults.scrollbackEditorCommand),
|
||||
stringValue(document, "worktree.relative_worktree_path", defaults.worktreeRelativePath),
|
||||
stringValue(document, "worktree.split_regex", defaults.worktreeSplitRegex),
|
||||
stringValue(document, "worktree.post_create_action", defaults.worktreePostCreateAction),
|
||||
stringListValue(document, "worktree.commands", defaults.worktreeCommands),
|
||||
booleanValue(document, "worktree.sync_panes", defaults.worktreeSyncPanes),
|
||||
closeSignalValue(document, defaults.closeSignal),
|
||||
envOverride(document, defaults.envOverride),
|
||||
keybindings(document, defaults)
|
||||
@@ -94,6 +107,11 @@ public record AppConfig(
|
||||
760.0,
|
||||
true,
|
||||
defaultScrollbackEditorCommand(),
|
||||
"./.worktrees",
|
||||
",",
|
||||
"none",
|
||||
List.of(),
|
||||
false,
|
||||
"SIGTERM",
|
||||
Map.of(),
|
||||
Map.ofEntries(
|
||||
@@ -111,6 +129,9 @@ public record AppConfig(
|
||||
Map.entry("next_tab", KeyBinding.parse("ALT+SHIFT+L")),
|
||||
Map.entry("open_font_selector", KeyBinding.parse("ALT+T")),
|
||||
Map.entry("open_scrollback", KeyBinding.parse("ALT+S")),
|
||||
Map.entry("create_worktree", KeyBinding.parse("ALT+W")),
|
||||
Map.entry("pane_sync_toggle", KeyBinding.parse("ALT+Y")),
|
||||
Map.entry("pane_sync_select", KeyBinding.parse("SPACE")),
|
||||
Map.entry("paste", KeyBinding.parse("CTRL+SHIFT+V"))
|
||||
)
|
||||
);
|
||||
@@ -128,6 +149,11 @@ public record AppConfig(
|
||||
windowHeight,
|
||||
kittyGraphics,
|
||||
scrollbackEditorCommand,
|
||||
worktreeRelativePath,
|
||||
worktreeSplitRegex,
|
||||
worktreePostCreateAction,
|
||||
worktreeCommands,
|
||||
worktreeSyncPanes,
|
||||
closeSignal,
|
||||
envOverride,
|
||||
keybindings
|
||||
@@ -230,6 +256,12 @@ public record AppConfig(
|
||||
builder.append("enabled = ").append(kittyGraphics).append("\n\n");
|
||||
builder.append("[scrollback]\n");
|
||||
builder.append("editor_command = ").append(quoted(scrollbackEditorCommand)).append("\n\n");
|
||||
builder.append("[worktree]\n");
|
||||
builder.append("relative_worktree_path = ").append(quoted(worktreeRelativePath)).append('\n');
|
||||
builder.append("split_regex = ").append(quoted(worktreeSplitRegex)).append('\n');
|
||||
builder.append("post_create_action = ").append(quoted(worktreePostCreateAction)).append('\n');
|
||||
builder.append("commands = ").append(quotedList(worktreeCommands)).append('\n');
|
||||
builder.append("sync_panes = ").append(worktreeSyncPanes).append("\n\n");
|
||||
builder.append("[env.override]\n");
|
||||
for (Map.Entry<String, String> entry : envOverride.entrySet()) {
|
||||
builder.append(entry.getKey()).append(" = ").append(quoted(entry.getValue())).append('\n');
|
||||
|
||||
@@ -20,6 +20,7 @@ import javafx.scene.text.TextAlignment;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -37,6 +38,10 @@ public final class Compositor {
|
||||
// Canvas background shown wherever no pane covers (gaps). Painted on a full recomposite.
|
||||
private static final Color GAP_BACKGROUND = Color.rgb(16, 16, 18);
|
||||
private static final Color TAB_TEXT = Color.rgb(225, 229, 235);
|
||||
private static final Color PANE_SYNC_SELECT_BORDER = Color.rgb(255, 183, 77);
|
||||
private static final Color PANE_SYNC_COMMITTED_BORDER = Color.rgb(105, 214, 128);
|
||||
// Keep this narrower than the terminal renderer's active border so focus remains visible.
|
||||
private static final double PANE_SYNC_BORDER_WIDTH = 1.0;
|
||||
// Thin tab strip shown at the top when more than one tab is open.
|
||||
private static final double TAB_BAR_HEIGHT = 22.0;
|
||||
|
||||
@@ -62,6 +67,9 @@ public final class Compositor {
|
||||
private final Map<TerminalPane, Long> hiddenSince = new HashMap<>();
|
||||
// Panes whose backbuffer is currently released, so we don't release again every frame.
|
||||
private final Set<TerminalPane> released = new HashSet<>();
|
||||
private final Set<TerminalPane> paneSyncSelection = new LinkedHashSet<>();
|
||||
private final Set<TerminalPane> paneSyncPanes = new LinkedHashSet<>();
|
||||
private boolean paneSyncSelectMode;
|
||||
// layoutVersion at the last sweep: lets an idle, all-released steady state skip the scan.
|
||||
private long lastSweepLayoutVersion = Long.MIN_VALUE;
|
||||
// Cheap per-frame dirty signal: skip the whole render when none of these changed.
|
||||
@@ -134,6 +142,67 @@ public final class Compositor {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isPaneSyncSelecting() {
|
||||
return paneSyncSelectMode;
|
||||
}
|
||||
|
||||
public void togglePaneSync() {
|
||||
if (paneSyncSelectMode) {
|
||||
paneSyncPanes.clear();
|
||||
paneSyncPanes.addAll(paneSyncSelection);
|
||||
paneSyncSelectMode = false;
|
||||
paneSyncSelection.clear();
|
||||
prunePaneSyncState();
|
||||
layoutVersion++;
|
||||
return;
|
||||
}
|
||||
if (!paneSyncPanes.isEmpty()) {
|
||||
paneSyncPanes.clear();
|
||||
layoutVersion++;
|
||||
return;
|
||||
}
|
||||
if (activePane() == null) {
|
||||
return;
|
||||
}
|
||||
paneSyncSelectMode = true;
|
||||
paneSyncSelection.clear();
|
||||
layoutVersion++;
|
||||
}
|
||||
|
||||
public void togglePaneSyncSelection() {
|
||||
TerminalPane active = activePane();
|
||||
if (active == null || !paneSyncSelectMode) {
|
||||
return;
|
||||
}
|
||||
if (!paneSyncSelection.add(active)) {
|
||||
paneSyncSelection.remove(active);
|
||||
}
|
||||
layoutVersion++;
|
||||
}
|
||||
|
||||
public List<TerminalPane> paneSyncPeers(TerminalPane source) {
|
||||
prunePaneSyncState();
|
||||
if (source == null || !paneSyncPanes.contains(source)) {
|
||||
return List.of();
|
||||
}
|
||||
return paneSyncPanes.stream()
|
||||
.filter(pane -> pane != source)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public void syncPanes(List<TerminalPane> panes) {
|
||||
paneSyncSelectMode = false;
|
||||
paneSyncSelection.clear();
|
||||
paneSyncPanes.clear();
|
||||
for (TerminalPane pane : panes) {
|
||||
if (pane != null) {
|
||||
paneSyncPanes.add(pane);
|
||||
}
|
||||
}
|
||||
prunePaneSyncState();
|
||||
layoutVersion++;
|
||||
}
|
||||
|
||||
public void toggleFloating() {
|
||||
mutateCurrentTab(() -> currentTab().toggleFloating());
|
||||
}
|
||||
@@ -142,6 +211,24 @@ public final class Compositor {
|
||||
mutateCurrentTab(() -> currentTab().createPane());
|
||||
}
|
||||
|
||||
public TerminalPane createTiledPane(String workingDirectory) {
|
||||
if (isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
TerminalPane pane = currentTab().createTiledPane(workingDirectory);
|
||||
layoutVersion++;
|
||||
return pane;
|
||||
}
|
||||
|
||||
public TerminalPane createFloatingPaneInDirectory(String workingDirectory) {
|
||||
if (isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
TerminalPane pane = currentTab().createFloatingPaneInDirectory(workingDirectory);
|
||||
layoutVersion++;
|
||||
return pane;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a floating pane running {@code command} directly (auto-closing when it exits), makes it
|
||||
* active, and returns it (null when no tab exists).
|
||||
@@ -190,6 +277,7 @@ public final class Compositor {
|
||||
for (int i = 0; i < tabs.size(); i++) {
|
||||
Tab tab = tabs.get(i);
|
||||
if (tab.closePane(pane)) {
|
||||
removePaneFromSyncState(pane);
|
||||
if (tab.isEmpty()) {
|
||||
// Closing a tab's last pane closes the tab. Keep currentTabIndex pointing at the
|
||||
// same tab (or clamp it when the current/last tab went away).
|
||||
@@ -238,6 +326,9 @@ public final class Compositor {
|
||||
tab.close();
|
||||
}
|
||||
tabs.clear();
|
||||
paneSyncSelectMode = false;
|
||||
paneSyncSelection.clear();
|
||||
paneSyncPanes.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -377,6 +468,7 @@ public final class Compositor {
|
||||
for (TerminalPane pane : panes) {
|
||||
paneContentVersion.put(pane, pane.paintFull(gc, isActive(pane)));
|
||||
}
|
||||
drawPaneSyncOverlay(gc, panes);
|
||||
imageOverlay.sync(panes);
|
||||
}
|
||||
|
||||
@@ -396,6 +488,7 @@ public final class Compositor {
|
||||
paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane)));
|
||||
imageOverlay.updatePane(pane);
|
||||
}
|
||||
drawPaneSyncOverlay(gc, panes);
|
||||
}
|
||||
|
||||
private GraphicsContext beginFrame() {
|
||||
@@ -431,6 +524,63 @@ public final class Compositor {
|
||||
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
||||
}
|
||||
|
||||
private void drawPaneSyncOverlay(GraphicsContext gc, List<TerminalPane> panes) {
|
||||
Set<TerminalPane> highlighted = paneSyncSelectMode ? paneSyncSelection : paneSyncPanes;
|
||||
if (highlighted.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
gc.save();
|
||||
try {
|
||||
gc.setLineWidth(PANE_SYNC_BORDER_WIDTH);
|
||||
gc.setStroke(paneSyncSelectMode ? PANE_SYNC_SELECT_BORDER : PANE_SYNC_COMMITTED_BORDER);
|
||||
for (TerminalPane pane : panes) {
|
||||
if (!highlighted.contains(pane)) {
|
||||
continue;
|
||||
}
|
||||
gc.save();
|
||||
double x = Math.round(pane.x()) + 2.0;
|
||||
double y = Math.round(pane.y()) + 2.0;
|
||||
double width = Math.max(0.0, pane.width() - 4.0);
|
||||
double height = Math.max(0.0, pane.height() - 4.0);
|
||||
TerminalRenderer.clip(gc, Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height(), pane.clip());
|
||||
gc.strokeRect(x, y, width, height);
|
||||
gc.restore();
|
||||
}
|
||||
} finally {
|
||||
gc.restore();
|
||||
}
|
||||
}
|
||||
|
||||
private void removePaneFromSyncState(TerminalPane pane) {
|
||||
boolean changed = paneSyncSelection.remove(pane);
|
||||
changed |= paneSyncPanes.remove(pane);
|
||||
if (paneSyncPanes.size() < 2) {
|
||||
changed |= !paneSyncPanes.isEmpty();
|
||||
paneSyncPanes.clear();
|
||||
}
|
||||
if (changed) {
|
||||
layoutVersion++;
|
||||
}
|
||||
}
|
||||
|
||||
private void prunePaneSyncState() {
|
||||
Set<TerminalPane> live = livePanes();
|
||||
paneSyncSelection.retainAll(live);
|
||||
paneSyncPanes.retainAll(live);
|
||||
if (paneSyncPanes.size() < 2) {
|
||||
paneSyncPanes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private Set<TerminalPane> livePanes() {
|
||||
Set<TerminalPane> live = new HashSet<>();
|
||||
for (Tab tab : tabs) {
|
||||
live.addAll(tab.allPanes());
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
// ---- Input ----------------------------------------------------------------------
|
||||
|
||||
private void handleMousePressed(MouseEvent event) {
|
||||
|
||||
@@ -4,7 +4,8 @@ package com.gregor.jprototerm;
|
||||
* Entry point and mode dispatch. A bare invocation is a thin client: it hands the request to a
|
||||
* running {@link Daemon}, or, if none is reachable, opens a single standalone window in this process
|
||||
* (today's behavior). {@code --daemon} runs the long-lived server that hosts every window in one
|
||||
* JVM, so client launches skip cold JVM/JavaFX/GL startup.
|
||||
* JVM, so client launches skip cold JVM/JavaFX/GL startup. {@code --standalone} skips daemon client
|
||||
* mode and always opens an in-process window, which is useful while testing development builds.
|
||||
*/
|
||||
public final class Main {
|
||||
private Main() {
|
||||
@@ -14,15 +15,19 @@ public final class Main {
|
||||
// Match the renderer order the app was tuned for; honor an explicit override if present.
|
||||
System.setProperty("prism.order", System.getProperty("prism.order", "es2,sw"));
|
||||
|
||||
boolean standalone = false;
|
||||
for (String arg : args) {
|
||||
if (arg.equals("--daemon")) {
|
||||
Daemon.run();
|
||||
return;
|
||||
}
|
||||
if (arg.equals("--standalone")) {
|
||||
standalone = true;
|
||||
}
|
||||
}
|
||||
|
||||
String workingDirectory = System.getProperty("user.dir");
|
||||
if (Daemon.tryClient(workingDirectory)) {
|
||||
if (!standalone && Daemon.tryClient(workingDirectory)) {
|
||||
return; // a running daemon opened the window
|
||||
}
|
||||
// No daemon reachable: fall back to a standalone window; the JVM exits when it closes.
|
||||
|
||||
@@ -237,12 +237,21 @@ final class Tab implements AutoCloseable {
|
||||
if (floatingVisible) {
|
||||
createFloatingPane();
|
||||
} else {
|
||||
TerminalPane pane = openPane(false);
|
||||
tiled.add(pane);
|
||||
setActive(pane);
|
||||
createTiledPane(paneWorkingDirectory());
|
||||
}
|
||||
}
|
||||
|
||||
TerminalPane createTiledPane(String workingDirectory) {
|
||||
TerminalPane pane = openPane(false, workingDirectory);
|
||||
tiled.add(pane);
|
||||
setActive(pane);
|
||||
return pane;
|
||||
}
|
||||
|
||||
TerminalPane createFloatingPaneInDirectory(String workingDirectory) {
|
||||
return addFloating(openPane(true, workingDirectory));
|
||||
}
|
||||
|
||||
void nextFloatingPane() {
|
||||
if (floating.isEmpty()) {
|
||||
createFloatingPane();
|
||||
@@ -334,7 +343,7 @@ final class Tab implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void createFloatingPane() {
|
||||
addFloating(openPane(true));
|
||||
addFloating(openPane(true, paneWorkingDirectory()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -386,9 +395,13 @@ final class Tab implements AutoCloseable {
|
||||
}
|
||||
|
||||
private TerminalPane openPane(boolean asFloating) {
|
||||
return openPane(asFloating, paneWorkingDirectory());
|
||||
}
|
||||
|
||||
private TerminalPane openPane(boolean asFloating, String workingDirectory) {
|
||||
double[] size = paneSize(asFloating);
|
||||
return register(TerminalPane.create(
|
||||
config, metrics, this::markContentChanged, size[0], size[1], paneWorkingDirectory()));
|
||||
config, metrics, this::markContentChanged, size[0], size[1], workingDirectory));
|
||||
}
|
||||
|
||||
private double[] paneSize(boolean asFloating) {
|
||||
|
||||
@@ -16,6 +16,8 @@ import javafx.application.Platform;
|
||||
import javafx.scene.canvas.GraphicsContext;
|
||||
import javafx.scene.shape.Shape;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Supplier;
|
||||
@@ -42,8 +44,8 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
||||
private RenderStateSnapshot cachedSnapshot;
|
||||
private volatile ShellSession session;
|
||||
// Run once (on the FX thread) when this pane's process exits on its own, so the owning tab can
|
||||
// remove it. Set by the Tab that creates the pane; null until then.
|
||||
private Runnable onExit;
|
||||
// remove it and command panes can run follow-up actions.
|
||||
private final List<Runnable> onExitHandlers = new ArrayList<>();
|
||||
private boolean exited;
|
||||
// Clip region for rendering (rect minus the panes covering this one), set at layout time;
|
||||
// null means clip to the plain bounds. See RenderTarget#clip().
|
||||
@@ -125,12 +127,20 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
||||
|
||||
/** Sets the callback run when this pane's process exits on its own (see {@link #handleSessionExit}). */
|
||||
public void setOnExit(Runnable onExit) {
|
||||
this.onExit = onExit;
|
||||
onExitHandlers.clear();
|
||||
addOnExit(onExit);
|
||||
}
|
||||
|
||||
/** Adds a callback run when this pane's process exits on its own (see {@link #handleSessionExit}). */
|
||||
public void addOnExit(Runnable onExit) {
|
||||
if (onExit != null) {
|
||||
onExitHandlers.add(onExit);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the shell reader thread when the pty stream ends without us closing it (the
|
||||
* process exited). Hops to the FX thread and fires {@link #onExit} once, so tab/compositor
|
||||
* process exited). Hops to the FX thread and fires the exit handlers once, so tab/compositor
|
||||
* mutation happens on the thread that owns the layout.
|
||||
*/
|
||||
void handleSessionExit() {
|
||||
@@ -139,8 +149,8 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
||||
return;
|
||||
}
|
||||
exited = true;
|
||||
if (onExit != null) {
|
||||
onExit.run();
|
||||
for (Runnable handler : List.copyOf(onExitHandlers)) {
|
||||
handler.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,8 +20,10 @@ import javafx.stage.Stage;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -71,6 +73,9 @@ final class TerminalWindow {
|
||||
keyActions.put("next_tab", compositor::nextTab);
|
||||
keyActions.put("open_font_selector", this::openFontSelector);
|
||||
keyActions.put("open_scrollback", this::openScrollbackInEditor);
|
||||
keyActions.put("create_worktree", this::createWorktreeInEditor);
|
||||
keyActions.put("pane_sync_toggle", compositor::togglePaneSync);
|
||||
keyActions.put("pane_sync_select", compositor::togglePaneSyncSelection);
|
||||
keyActions.put("paste", this::pasteFromClipboard);
|
||||
|
||||
StackPane root = new StackPane(compositor.canvas(), compositor.imageOverlay());
|
||||
@@ -130,19 +135,51 @@ final class TerminalWindow {
|
||||
|
||||
private void handlePressed(KeyEvent event) {
|
||||
for (Map.Entry<String, Runnable> action : keyActions.entrySet()) {
|
||||
if (config.keybindings().get(action.getKey()).matches(event)) {
|
||||
String actionName = action.getKey();
|
||||
if (config.keybindings().get(actionName).matches(event)) {
|
||||
if (actionName.equals("pane_sync_select") && !compositor.isPaneSyncSelecting()) {
|
||||
continue;
|
||||
}
|
||||
if (compositor.isPaneSyncSelecting() && !allowedDuringPaneSyncSelection(actionName)) {
|
||||
event.consume();
|
||||
return;
|
||||
}
|
||||
action.getValue().run();
|
||||
event.consume();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (compositor.isPaneSyncSelecting()) {
|
||||
event.consume();
|
||||
return;
|
||||
}
|
||||
String encoded = KeyEncoder.encode(event);
|
||||
if (encoded != null) {
|
||||
sendToActivePane(encoded, event);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean allowedDuringPaneSyncSelection(String action) {
|
||||
return switch (action) {
|
||||
case "navigate_left",
|
||||
"navigate_down",
|
||||
"navigate_up",
|
||||
"navigate_right",
|
||||
"toggle_floating",
|
||||
"next_floating",
|
||||
"previous_tab",
|
||||
"next_tab",
|
||||
"pane_sync_toggle",
|
||||
"pane_sync_select" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private void handleTyped(KeyEvent event) {
|
||||
if (compositor.isPaneSyncSelecting()) {
|
||||
event.consume();
|
||||
return;
|
||||
}
|
||||
if (event.isAltDown() || event.isControlDown() || event.isMetaDown()) {
|
||||
return;
|
||||
}
|
||||
@@ -159,6 +196,9 @@ final class TerminalWindow {
|
||||
TerminalPane active = compositor.activePane();
|
||||
if (active != null) {
|
||||
active.send(text);
|
||||
for (TerminalPane peer : compositor.paneSyncPeers(active)) {
|
||||
peer.send(text);
|
||||
}
|
||||
event.consume();
|
||||
}
|
||||
}
|
||||
@@ -167,7 +207,11 @@ final class TerminalWindow {
|
||||
TerminalPane active = compositor.activePane();
|
||||
Clipboard clipboard = Clipboard.getSystemClipboard();
|
||||
if (active != null && clipboard.hasString()) {
|
||||
active.paste(clipboard.getString());
|
||||
String text = clipboard.getString();
|
||||
active.paste(text);
|
||||
for (TerminalPane peer : compositor.paneSyncPeers(active)) {
|
||||
peer.paste(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,13 +274,36 @@ final class TerminalWindow {
|
||||
// — no shell startup/rc race — and the pane auto-closes when the editor exits. The
|
||||
// trailing rm removes the file (which holds terminal contents) when the editor exits;
|
||||
// deleteOnExit would leak files for the JVM's whole lifetime in daemon mode.
|
||||
compositor.openFloatingPane(scrollbackEditorCommand(file) + "; rm -f " + shellQuote(file.toString()));
|
||||
compositor.openFloatingPane(editorCommand(file) + "; rm -f " + shellQuote(file.toString()));
|
||||
} catch (IOException ex) {
|
||||
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String scrollbackEditorCommand(Path file) {
|
||||
private void createWorktreeInEditor() {
|
||||
// The floating pane command inherits the active pane's cwd at creation time, so the git
|
||||
// worktree command runs from the pane that was focused before this shortcut opened.
|
||||
TerminalPane active = compositor.activePane();
|
||||
if (active == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Path file = Files.createTempFile("jprototerm-worktree-", ".txt");
|
||||
Path createdFile = Files.createTempFile("jprototerm-worktree-created-", ".txt");
|
||||
Files.writeString(file, "");
|
||||
|
||||
TerminalPane commandPane = compositor.openFloatingPane(worktreeEditorCommand(file, createdFile));
|
||||
if (commandPane != null) {
|
||||
commandPane.addOnExit(() -> runPostWorktreeAction(active, createdFile));
|
||||
} else {
|
||||
Files.deleteIfExists(createdFile);
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
System.err.println("Could not create worktree from editor input: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String editorCommand(Path file) {
|
||||
String quotedFile = shellQuote(file.toString());
|
||||
String command = config.scrollbackEditorCommand();
|
||||
if (command == null || command.isBlank()) {
|
||||
@@ -248,6 +315,112 @@ final class TerminalWindow {
|
||||
return command + " " + quotedFile;
|
||||
}
|
||||
|
||||
private String worktreeEditorCommand(Path file, Path createdFile) {
|
||||
String quotedFile = shellQuote(file.toString());
|
||||
String quotedCreatedFile = shellQuote(createdFile.toString());
|
||||
String relativePath = config.worktreeRelativePath();
|
||||
if (relativePath == null || relativePath.isBlank()) {
|
||||
relativePath = "./.worktrees";
|
||||
}
|
||||
String splitRegex = config.worktreeSplitRegex();
|
||||
if (splitRegex == null || splitRegex.isBlank()) {
|
||||
splitRegex = ",";
|
||||
}
|
||||
|
||||
return editorCommand(file)
|
||||
+ "; editor_status=$?"
|
||||
+ "; git_status=$editor_status"
|
||||
+ "; if [ \"$editor_status\" -eq 0 ]; then"
|
||||
+ " if names_file=$(mktemp); then"
|
||||
+ " if awk -v re=" + shellQuote(splitRegex)
|
||||
+ " '{ text = text $0 \"\\n\" }"
|
||||
+ " END { n = split(text, names, re); for (i = 1; i <= n; i++)"
|
||||
+ " { name = names[i]; sub(/^[[:space:]]+/, \"\", name);"
|
||||
+ " sub(/[[:space:]]+$/, \"\", name); if (name != \"\") print name; } }'"
|
||||
+ " " + quotedFile + " > \"$names_file\"; then"
|
||||
+ " git_status=0"
|
||||
+ "; while IFS= read -r name; do"
|
||||
+ " worktree_path=" + shellQuote(relativePath) + "/\"$name\""
|
||||
+ "; git worktree add \"$worktree_path\""
|
||||
+ " || { git_status=$?; break; }"
|
||||
+ "; created_path=$(cd \"$worktree_path\" && pwd -P)"
|
||||
+ " || { git_status=$?; break; }"
|
||||
+ "; printf '%s\\n' \"$created_path\" >> " + quotedCreatedFile
|
||||
+ " || { git_status=$?; break; }"
|
||||
+ "; done < \"$names_file\""
|
||||
+ "; else git_status=$?"
|
||||
+ "; fi"
|
||||
+ "; rm -f \"$names_file\""
|
||||
+ "; else git_status=$?"
|
||||
+ "; fi"
|
||||
+ "; fi"
|
||||
+ "; rm -f " + quotedFile
|
||||
+ "; exit \"$git_status\"";
|
||||
}
|
||||
|
||||
private void runPostWorktreeAction(TerminalPane lastActivePane, Path createdFile) {
|
||||
List<String> worktreePaths = readCreatedWorktreePaths(createdFile);
|
||||
try {
|
||||
Files.deleteIfExists(createdFile);
|
||||
} catch (IOException ex) {
|
||||
System.err.println("Could not remove worktree result file " + createdFile + ": " + ex.getMessage());
|
||||
}
|
||||
if (worktreePaths.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String action = config.worktreePostCreateAction();
|
||||
if (action == null || action.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action.trim().toLowerCase(Locale.ROOT)) {
|
||||
case "none" -> { }
|
||||
case "cd" -> lastActivePane.send("cd " + shellQuote(worktreePaths.get(worktreePaths.size() - 1)) + "\r");
|
||||
case "create_panes" -> createWorktreePanes(worktreePaths, false);
|
||||
case "create_panes_floating" -> createWorktreePanes(worktreePaths, true);
|
||||
default -> System.err.println("Unknown worktree.post_create_action '" + action + "'");
|
||||
}
|
||||
}
|
||||
|
||||
private void createWorktreePanes(List<String> worktreePaths, boolean floating) {
|
||||
List<String> commands = config.worktreeCommands();
|
||||
List<TerminalPane> createdPanes = new ArrayList<>();
|
||||
for (int i = 0; i < worktreePaths.size(); i++) {
|
||||
TerminalPane pane = floating
|
||||
? compositor.createFloatingPaneInDirectory(worktreePaths.get(i))
|
||||
: compositor.createTiledPane(worktreePaths.get(i));
|
||||
if (pane != null) {
|
||||
createdPanes.add(pane);
|
||||
}
|
||||
if (pane != null && !commands.isEmpty()) {
|
||||
String command = commands.get(i % commands.size());
|
||||
if (command != null && !command.isBlank()) {
|
||||
pane.send(command + "\r");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (config.worktreeSyncPanes() && !createdPanes.isEmpty()) {
|
||||
compositor.syncPanes(createdPanes);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> readCreatedWorktreePaths(Path createdFile) {
|
||||
try {
|
||||
List<String> paths = new ArrayList<>();
|
||||
for (String line : Files.readAllLines(createdFile)) {
|
||||
String path = line.trim();
|
||||
if (!path.isEmpty()) {
|
||||
paths.add(path);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
} catch (IOException ex) {
|
||||
System.err.println("Could not read created worktree paths from " + createdFile + ": " + ex.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static String shellQuote(String value) {
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user