6 Commits

Author SHA1 Message Date
2c4b89f8f7 Merge branch 'add-worktree-features' 2026-06-19 17:10:46 +02:00
cff4fba81c post create pane sync 2026-06-19 16:44:16 +02:00
5a50ce62f3 post create run commands in panes 2026-06-19 16:41:31 +02:00
27b7ba6904 post create none action 2026-06-19 16:15:10 +02:00
7b3a370371 post worktree creation actions 2026-06-19 16:08:17 +02:00
4e2ed7f99f multiple worktree creation 2026-06-19 16:02:06 +02:00
7 changed files with 206 additions and 21 deletions

View File

@@ -151,6 +151,11 @@ editor_command = "vi {file}"
[worktree] [worktree]
relative_worktree_path = "./.worktrees" 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] [env.override]
ZELLIJ_SESSION_NAME = "" ZELLIJ_SESSION_NAME = ""
@@ -188,8 +193,14 @@ paste = "CTRL+SHIFT+V"
- `Alt+Shift+h` / `Alt+Shift+l`: previous / next tab - `Alt+Shift+h` / `Alt+Shift+l`: previous / next tab
- `Alt+t`: open the font selector - `Alt+t`: open the font selector
- `Alt+s`: open the active pane scrollback in `$EDITOR` - `Alt+s`: open the active pane scrollback in `$EDITOR`
- `Alt+w`: edit a worktree name, then run `git worktree add <relative_worktree_path>/<name>` - `Alt+w`: edit one or more worktree names, split by `worktree.split_regex`, then run
from the previously focused pane's working directory `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 - `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 - `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 - Once committed, input typed or pasted into any synced pane is mirrored to the other synced panes

View File

@@ -21,6 +21,11 @@ editor_command = "vi {file}"
[worktree] [worktree]
relative_worktree_path = "./.worktrees" 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] [env.override]
ZELLIJ_SESSION_NAME = "" ZELLIJ_SESSION_NAME = ""

View File

@@ -30,6 +30,10 @@ public record AppConfig(
boolean kittyGraphics, boolean kittyGraphics,
String scrollbackEditorCommand, String scrollbackEditorCommand,
String worktreeRelativePath, String worktreeRelativePath,
String worktreeSplitRegex,
String worktreePostCreateAction,
List<String> worktreeCommands,
boolean worktreeSyncPanes,
String closeSignal, String closeSignal,
Map<String, String> envOverride, Map<String, String> envOverride,
Map<String, KeyBinding> keybindings Map<String, KeyBinding> keybindings
@@ -77,6 +81,10 @@ public record AppConfig(
booleanValue(document, "kitty_graphics.enabled", defaults.kittyGraphics), booleanValue(document, "kitty_graphics.enabled", defaults.kittyGraphics),
stringValue(document, "scrollback.editor_command", defaults.scrollbackEditorCommand), stringValue(document, "scrollback.editor_command", defaults.scrollbackEditorCommand),
stringValue(document, "worktree.relative_worktree_path", defaults.worktreeRelativePath), 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), closeSignalValue(document, defaults.closeSignal),
envOverride(document, defaults.envOverride), envOverride(document, defaults.envOverride),
keybindings(document, defaults) keybindings(document, defaults)
@@ -100,6 +108,10 @@ public record AppConfig(
true, true,
defaultScrollbackEditorCommand(), defaultScrollbackEditorCommand(),
"./.worktrees", "./.worktrees",
",",
"none",
List.of(),
false,
"SIGTERM", "SIGTERM",
Map.of(), Map.of(),
Map.ofEntries( Map.ofEntries(
@@ -138,6 +150,10 @@ public record AppConfig(
kittyGraphics, kittyGraphics,
scrollbackEditorCommand, scrollbackEditorCommand,
worktreeRelativePath, worktreeRelativePath,
worktreeSplitRegex,
worktreePostCreateAction,
worktreeCommands,
worktreeSyncPanes,
closeSignal, closeSignal,
envOverride, envOverride,
keybindings keybindings
@@ -241,7 +257,11 @@ public record AppConfig(
builder.append("[scrollback]\n"); builder.append("[scrollback]\n");
builder.append("editor_command = ").append(quoted(scrollbackEditorCommand)).append("\n\n"); builder.append("editor_command = ").append(quoted(scrollbackEditorCommand)).append("\n\n");
builder.append("[worktree]\n"); builder.append("[worktree]\n");
builder.append("relative_worktree_path = ").append(quoted(worktreeRelativePath)).append("\n\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"); builder.append("[env.override]\n");
for (Map.Entry<String, String> entry : envOverride.entrySet()) { for (Map.Entry<String, String> entry : envOverride.entrySet()) {
builder.append(entry.getKey()).append(" = ").append(quoted(entry.getValue())).append('\n'); builder.append(entry.getKey()).append(" = ").append(quoted(entry.getValue())).append('\n');

View File

@@ -188,6 +188,19 @@ public final class Compositor {
.toList(); .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() { public void toggleFloating() {
mutateCurrentTab(() -> currentTab().toggleFloating()); mutateCurrentTab(() -> currentTab().toggleFloating());
} }
@@ -196,6 +209,24 @@ public final class Compositor {
mutateCurrentTab(() -> currentTab().createPane()); 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 * Opens a floating pane running {@code command} directly (auto-closing when it exits), makes it
* active, and returns it (null when no tab exists). * active, and returns it (null when no tab exists).

View File

@@ -237,10 +237,19 @@ final class Tab implements AutoCloseable {
if (floatingVisible) { if (floatingVisible) {
createFloatingPane(); createFloatingPane();
} else { } else {
TerminalPane pane = openPane(false); createTiledPane(paneWorkingDirectory());
}
}
TerminalPane createTiledPane(String workingDirectory) {
TerminalPane pane = openPane(false, workingDirectory);
tiled.add(pane); tiled.add(pane);
setActive(pane); setActive(pane);
return pane;
} }
TerminalPane createFloatingPaneInDirectory(String workingDirectory) {
return addFloating(openPane(true, workingDirectory));
} }
void nextFloatingPane() { void nextFloatingPane() {
@@ -334,7 +343,7 @@ final class Tab implements AutoCloseable {
} }
private void createFloatingPane() { private void createFloatingPane() {
addFloating(openPane(true)); addFloating(openPane(true, paneWorkingDirectory()));
} }
/** /**
@@ -386,9 +395,13 @@ final class Tab implements AutoCloseable {
} }
private TerminalPane openPane(boolean asFloating) { private TerminalPane openPane(boolean asFloating) {
return openPane(asFloating, paneWorkingDirectory());
}
private TerminalPane openPane(boolean asFloating, String workingDirectory) {
double[] size = paneSize(asFloating); double[] size = paneSize(asFloating);
return register(TerminalPane.create( 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) { private double[] paneSize(boolean asFloating) {

View File

@@ -16,6 +16,8 @@ import javafx.application.Platform;
import javafx.scene.canvas.GraphicsContext; import javafx.scene.canvas.GraphicsContext;
import javafx.scene.shape.Shape; import javafx.scene.shape.Shape;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier; import java.util.function.Supplier;
@@ -42,8 +44,8 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
private RenderStateSnapshot cachedSnapshot; private RenderStateSnapshot cachedSnapshot;
private volatile ShellSession session; private volatile ShellSession session;
// Run once (on the FX thread) when this pane's process exits on its own, so the owning tab can // 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. // remove it and command panes can run follow-up actions.
private Runnable onExit; private final List<Runnable> onExitHandlers = new ArrayList<>();
private boolean exited; private boolean exited;
// Clip region for rendering (rect minus the panes covering this one), set at layout time; // 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(). // 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}). */ /** Sets the callback run when this pane's process exits on its own (see {@link #handleSessionExit}). */
public void setOnExit(Runnable onExit) { 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 * 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. * mutation happens on the thread that owns the layout.
*/ */
void handleSessionExit() { void handleSessionExit() {
@@ -139,8 +149,8 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
return; return;
} }
exited = true; exited = true;
if (onExit != null) { for (Runnable handler : List.copyOf(onExitHandlers)) {
onExit.run(); handler.run();
} }
}); });
} }

View File

@@ -20,8 +20,10 @@ import javafx.stage.Stage;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
/** /**
@@ -287,9 +289,15 @@ final class TerminalWindow {
} }
try { try {
Path file = Files.createTempFile("jprototerm-worktree-", ".txt"); Path file = Files.createTempFile("jprototerm-worktree-", ".txt");
Path createdFile = Files.createTempFile("jprototerm-worktree-created-", ".txt");
Files.writeString(file, ""); Files.writeString(file, "");
compositor.openFloatingPane(worktreeEditorCommand(file)); TerminalPane commandPane = compositor.openFloatingPane(worktreeEditorCommand(file, createdFile));
if (commandPane != null) {
commandPane.addOnExit(() -> runPostWorktreeAction(active, createdFile));
} else {
Files.deleteIfExists(createdFile);
}
} catch (IOException ex) { } catch (IOException ex) {
System.err.println("Could not create worktree from editor input: " + ex.getMessage()); System.err.println("Could not create worktree from editor input: " + ex.getMessage());
} }
@@ -307,25 +315,112 @@ final class TerminalWindow {
return command + " " + quotedFile; return command + " " + quotedFile;
} }
private String worktreeEditorCommand(Path file) { private String worktreeEditorCommand(Path file, Path createdFile) {
String quotedFile = shellQuote(file.toString()); String quotedFile = shellQuote(file.toString());
String quotedCreatedFile = shellQuote(createdFile.toString());
String relativePath = config.worktreeRelativePath(); String relativePath = config.worktreeRelativePath();
if (relativePath == null || relativePath.isBlank()) { if (relativePath == null || relativePath.isBlank()) {
relativePath = "./.worktrees"; relativePath = "./.worktrees";
} }
String splitRegex = config.worktreeSplitRegex();
if (splitRegex == null || splitRegex.isBlank()) {
splitRegex = ",";
}
return editorCommand(file) return editorCommand(file)
+ "; editor_status=$?" + "; editor_status=$?"
+ "; name=$(cat " + quotedFile + ")" + "; git_status=$editor_status"
+ "; if [ \"$editor_status\" -eq 0 ] && [ -n \"$name\" ]; then" + "; if [ \"$editor_status\" -eq 0 ]; then"
+ " git worktree add " + shellQuote(relativePath) + "/\"$name\"" + " if names_file=$(mktemp); then"
+ "; git_status=$?" + " if awk -v re=" + shellQuote(splitRegex)
+ "; else git_status=$editor_status" + " '{ 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" + "; fi"
+ "; rm -f " + quotedFile + "; rm -f " + quotedFile
+ "; exit \"$git_status\""; + "; 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) { private static String shellQuote(String value) {
return "'" + value.replace("'", "'\"'\"'") + "'"; return "'" + value.replace("'", "'\"'\"'") + "'";
} }