some guard rails
This commit is contained in:
@@ -114,7 +114,7 @@ public final class Compositor {
|
|||||||
public void setFont(String family, double size) {
|
public void setFont(String family, double size) {
|
||||||
metrics.setFont(family, size);
|
metrics.setFont(family, size);
|
||||||
paneContentVersion.clear();
|
paneContentVersion.clear();
|
||||||
lastWidth = -1.0; // force a redraw on the next frame
|
layoutVersion++; // recomposite with the new metrics on the next frame
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Tabs and panes -------------------------------------------------------------
|
// ---- Tabs and panes -------------------------------------------------------------
|
||||||
@@ -123,8 +123,9 @@ public final class Compositor {
|
|||||||
return tabs.isEmpty();
|
return tabs.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The active pane of the current tab, or {@code null} when no tab is left. */
|
||||||
public TerminalPane activePane() {
|
public TerminalPane activePane() {
|
||||||
return currentTab().activePane();
|
return isEmpty() ? null : currentTab().activePane();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void navigate(Direction direction) {
|
public void navigate(Direction direction) {
|
||||||
@@ -173,10 +174,7 @@ public final class Compositor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void closeActivePane() {
|
public void closeActivePane() {
|
||||||
if (isEmpty()) {
|
TerminalPane active = activePane();
|
||||||
return;
|
|
||||||
}
|
|
||||||
TerminalPane active = currentTab().activePane();
|
|
||||||
if (active != null) {
|
if (active != null) {
|
||||||
closePane(active);
|
closePane(active);
|
||||||
}
|
}
|
||||||
@@ -214,7 +212,8 @@ public final class Compositor {
|
|||||||
public void newTab() {
|
public void newTab() {
|
||||||
// Open the new tab in the currently active pane's working directory, so it lands where the
|
// Open the new tab in the currently active pane's working directory, so it lands where the
|
||||||
// user currently is rather than always in home.
|
// user currently is rather than always in home.
|
||||||
String workingDirectory = isEmpty() ? null : currentTab().activePane().currentWorkingDirectory();
|
TerminalPane active = activePane();
|
||||||
|
String workingDirectory = active != null ? active.currentWorkingDirectory() : null;
|
||||||
tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
|
tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
|
||||||
currentTabIndex = tabs.size() - 1;
|
currentTabIndex = tabs.size() - 1;
|
||||||
layoutVersion++;
|
layoutVersion++;
|
||||||
@@ -454,11 +453,11 @@ public final class Compositor {
|
|||||||
private void handleMouseReleased(MouseEvent event) {
|
private void handleMouseReleased(MouseEvent event) {
|
||||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||||
if (pane == null) {
|
if (pane == null) {
|
||||||
pane = activePane();
|
pane = activePane(); // released outside every pane (e.g. mid-drag): route to the active one
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
||||||
MouseTarget target = mouseTarget(pane);
|
MouseTarget target = pane == null ? null : mouseTarget(pane);
|
||||||
if (target != null) {
|
if (target != null) {
|
||||||
send(pane, target, MouseInput.release(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), false, event);
|
send(pane, target, MouseInput.release(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), false, event);
|
||||||
}
|
}
|
||||||
@@ -469,7 +468,10 @@ public final class Compositor {
|
|||||||
private void handleMouseDragged(MouseEvent event) {
|
private void handleMouseDragged(MouseEvent event) {
|
||||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||||
if (pane == null) {
|
if (pane == null) {
|
||||||
pane = activePane();
|
pane = activePane(); // dragged outside every pane: route to the active one
|
||||||
|
}
|
||||||
|
if (pane == null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import java.nio.channels.ServerSocketChannel;
|
|||||||
import java.nio.channels.SocketChannel;
|
import java.nio.channels.SocketChannel;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.LinkOption;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.attribute.PosixFilePermissions;
|
import java.nio.file.attribute.PosixFilePermissions;
|
||||||
|
|
||||||
@@ -23,6 +24,11 @@ import java.nio.file.attribute.PosixFilePermissions;
|
|||||||
* (mode 0700), so only the owning user can connect.
|
* (mode 0700), so only the owning user can connect.
|
||||||
*/
|
*/
|
||||||
public final class Daemon {
|
public final class Daemon {
|
||||||
|
// One request is a single line holding a filesystem path; anything bigger is bogus.
|
||||||
|
private static final int MAX_REQUEST_BYTES = 4096;
|
||||||
|
// The accept loop is single-threaded, so a client that stalls must not wedge the daemon.
|
||||||
|
private static final long READ_TIMEOUT_NANOS = 5_000_000_000L;
|
||||||
|
|
||||||
private Daemon() {
|
private Daemon() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,9 +37,9 @@ public final class Daemon {
|
|||||||
Path socket = socketPath();
|
Path socket = socketPath();
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(socket.getParent());
|
Files.createDirectories(socket.getParent());
|
||||||
trySecureDir(socket.getParent());
|
secureDir(socket.getParent());
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
System.err.println("jprototerm: cannot create socket dir " + socket.getParent() + ": " + ex.getMessage());
|
System.err.println("jprototerm: cannot secure socket dir " + socket.getParent() + ": " + ex.getMessage());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,14 +113,22 @@ public final class Daemon {
|
|||||||
manager.openWindow(workingDirectory == null || workingDirectory.isBlank()
|
manager.openWindow(workingDirectory == null || workingDirectory.isBlank()
|
||||||
? null
|
? null
|
||||||
: workingDirectory.trim());
|
: workingDirectory.trim());
|
||||||
|
connection.configureBlocking(true);
|
||||||
connection.write(ByteBuffer.wrap("OK\n".getBytes(StandardCharsets.UTF_8)));
|
connection.write(ByteBuffer.wrap("OK\n".getBytes(StandardCharsets.UTF_8)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reads the request line non-blocking with a deadline and a size cap: the accept loop is
|
||||||
|
// single-threaded, so a client that stalls or never sends a newline must fail the connection
|
||||||
|
// (an IOException logged by run()) rather than wedge the daemon or grow the buffer unbounded.
|
||||||
private static String readLine(SocketChannel channel) throws IOException {
|
private static String readLine(SocketChannel channel) throws IOException {
|
||||||
|
channel.configureBlocking(false);
|
||||||
|
long deadline = System.nanoTime() + READ_TIMEOUT_NANOS;
|
||||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
ByteBuffer buffer = ByteBuffer.allocate(4096);
|
ByteBuffer buffer = ByteBuffer.allocate(4096);
|
||||||
while (channel.read(buffer) != -1) {
|
while (true) {
|
||||||
|
int n = channel.read(buffer);
|
||||||
|
if (n > 0) {
|
||||||
buffer.flip();
|
buffer.flip();
|
||||||
while (buffer.hasRemaining()) {
|
while (buffer.hasRemaining()) {
|
||||||
byte b = buffer.get();
|
byte b = buffer.get();
|
||||||
@@ -122,10 +136,25 @@ public final class Daemon {
|
|||||||
return out.toString(StandardCharsets.UTF_8);
|
return out.toString(StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
out.write(b);
|
out.write(b);
|
||||||
|
if (out.size() > MAX_REQUEST_BYTES) {
|
||||||
|
throw new IOException("request line too long");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
}
|
} else if (n == -1) {
|
||||||
return out.size() == 0 ? null : out.toString(StandardCharsets.UTF_8);
|
return out.size() == 0 ? null : out.toString(StandardCharsets.UTF_8);
|
||||||
|
} else {
|
||||||
|
if (System.nanoTime() >= deadline) {
|
||||||
|
throw new IOException("request timed out");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(5);
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IOException("interrupted while reading request");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Path socketPath() {
|
private static Path socketPath() {
|
||||||
@@ -136,11 +165,19 @@ public final class Daemon {
|
|||||||
return dir.resolve("daemon.sock");
|
return dir.resolve("daemon.sock");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void trySecureDir(Path dir) {
|
// Make the socket dir private, and refuse to use it if it is not ours. The /tmp fallback
|
||||||
|
// path is predictable, so another user could have pre-created it (the classic /tmp race);
|
||||||
|
// binding a socket inside a directory someone else owns would hand them control of it.
|
||||||
|
private static void secureDir(Path dir) throws IOException {
|
||||||
try {
|
try {
|
||||||
Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwx------"));
|
Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwx------"));
|
||||||
} catch (IOException | UnsupportedOperationException ignored) {
|
} catch (UnsupportedOperationException ignored) {
|
||||||
// Best effort: XDG_RUNTIME_DIR is already user-private; the /tmp fallback we try to lock.
|
return; // not a POSIX filesystem: nothing more we can check
|
||||||
|
}
|
||||||
|
String owner = Files.getOwner(dir, LinkOption.NOFOLLOW_LINKS).getName();
|
||||||
|
String user = System.getProperty("user.name");
|
||||||
|
if (!owner.equals(user)) {
|
||||||
|
throw new IOException(dir + " is owned by '" + owner + "', not '" + user + "'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ final class GlyphCache {
|
|||||||
record Glyph(int width, int height, byte[] alpha) {
|
record Glyph(int width, int height, byte[] alpha) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bounds the atlas so pathological glyph diversity (e.g. a dump of distinct CJK/emoji cells)
|
||||||
|
// can't grow it without limit; on overflow it clears and rebuilds on demand, like any
|
||||||
|
// metrics change.
|
||||||
|
private static final int MAX_GLYPHS = 4096;
|
||||||
|
|
||||||
private final TerminalMetrics metrics;
|
private final TerminalMetrics metrics;
|
||||||
private final Map<String, Glyph> glyphs = new HashMap<>();
|
private final Map<String, Glyph> glyphs = new HashMap<>();
|
||||||
// The metrics snapshot the cached glyphs were rasterized for; a mismatch clears the cache.
|
// The metrics snapshot the cached glyphs were rasterized for; a mismatch clears the cache.
|
||||||
@@ -43,7 +48,16 @@ final class GlyphCache {
|
|||||||
|
|
||||||
Glyph glyph(String text) {
|
Glyph glyph(String text) {
|
||||||
ensureCurrent();
|
ensureCurrent();
|
||||||
return glyphs.computeIfAbsent(text, this::renderGlyph);
|
Glyph cached = glyphs.get(text);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
if (glyphs.size() >= MAX_GLYPHS) {
|
||||||
|
glyphs.clear();
|
||||||
|
}
|
||||||
|
Glyph rendered = renderGlyph(text);
|
||||||
|
glyphs.put(text, rendered);
|
||||||
|
return rendered;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop the rasterized masks if the font/cell geometry changed since they were built. Cheap to
|
// Drop the rasterized masks if the font/cell geometry changed since they were built. Cheap to
|
||||||
|
|||||||
@@ -5,17 +5,19 @@ import javafx.scene.input.KeyEvent;
|
|||||||
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode code) {
|
public record KeyBinding(boolean alt, boolean control, boolean shift, boolean meta, KeyCode code) {
|
||||||
public static KeyBinding parse(String value) {
|
public static KeyBinding parse(String value) {
|
||||||
boolean alt = false;
|
boolean alt = false;
|
||||||
boolean control = false;
|
boolean control = false;
|
||||||
boolean shift = false;
|
boolean shift = false;
|
||||||
|
boolean meta = false;
|
||||||
KeyCode code = null;
|
KeyCode code = null;
|
||||||
|
|
||||||
for (String part : value.split("\\+")) {
|
for (String part : value.split("\\+")) {
|
||||||
String token = part.trim().toUpperCase(Locale.ROOT);
|
String token = part.trim().toUpperCase(Locale.ROOT);
|
||||||
switch (token) {
|
switch (token) {
|
||||||
case "ALT", "META" -> alt = true;
|
case "ALT" -> alt = true;
|
||||||
|
case "META", "SUPER" -> meta = true;
|
||||||
case "CTRL", "CONTROL" -> control = true;
|
case "CTRL", "CONTROL" -> control = true;
|
||||||
case "SHIFT" -> shift = true;
|
case "SHIFT" -> shift = true;
|
||||||
default -> code = keyCode(token);
|
default -> code = keyCode(token);
|
||||||
@@ -25,13 +27,14 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
|
|||||||
if (code == null) {
|
if (code == null) {
|
||||||
throw new IllegalArgumentException("Key binding has no key code: " + value);
|
throw new IllegalArgumentException("Key binding has no key code: " + value);
|
||||||
}
|
}
|
||||||
return new KeyBinding(alt, control, shift, code);
|
return new KeyBinding(alt, control, shift, meta, code);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean matches(KeyEvent event) {
|
public boolean matches(KeyEvent event) {
|
||||||
return event.isAltDown() == alt
|
return event.isAltDown() == alt
|
||||||
&& event.isControlDown() == control
|
&& event.isControlDown() == control
|
||||||
&& event.isShiftDown() == shift
|
&& event.isShiftDown() == shift
|
||||||
|
&& event.isMetaDown() == meta
|
||||||
&& event.getCode() == code;
|
&& event.getCode() == code;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +47,9 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
|
|||||||
if (alt) {
|
if (alt) {
|
||||||
builder.append("ALT+");
|
builder.append("ALT+");
|
||||||
}
|
}
|
||||||
|
if (meta) {
|
||||||
|
builder.append("META+");
|
||||||
|
}
|
||||||
if (shift) {
|
if (shift) {
|
||||||
builder.append("SHIFT+");
|
builder.append("SHIFT+");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -317,6 +317,10 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void closeMaster() {
|
private void closeMaster() {
|
||||||
|
// Note: closing the master fd does NOT wake a reader thread blocked in read() on it —
|
||||||
|
// the reader unblocks via EOF when the child exits and the slave end closes. The signal
|
||||||
|
// here usually does that; if the child ignores it, the SIGKILL escalation in reap()
|
||||||
|
// guarantees it shortly after.
|
||||||
callKill(pid, closeSignal);
|
callKill(pid, closeSignal);
|
||||||
callInt(CLOSE, masterFd);
|
callInt(CLOSE, masterFd);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ final class StartupTiming {
|
|||||||
private StartupTiming() {
|
private StartupTiming() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Records a phase boundary, printing the delta since the previous mark and since JVM start. */
|
/**
|
||||||
static void mark(String phase) {
|
* Records a phase boundary, printing the delta since the previous mark and since JVM start.
|
||||||
|
* Synchronized because marks come from both the launcher thread and the FX thread.
|
||||||
|
*/
|
||||||
|
static synchronized void mark(String phase) {
|
||||||
if (!ENABLED) {
|
if (!ENABLED) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -38,7 +41,7 @@ final class StartupTiming {
|
|||||||
* Records the first rendered frame exactly once, then becomes a no-op. Safe and cheap to call
|
* Records the first rendered frame exactly once, then becomes a no-op. Safe and cheap to call
|
||||||
* from the render loop every frame (it only ever touches FX-thread state).
|
* from the render loop every frame (it only ever touches FX-thread state).
|
||||||
*/
|
*/
|
||||||
static void firstFrame() {
|
static synchronized void firstFrame() {
|
||||||
if (!ENABLED || firstFrameSeen) {
|
if (!ENABLED || firstFrameSeen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ import java.util.stream.Stream;
|
|||||||
* return whether they actually changed anything so it can bump its layout version.
|
* return whether they actually changed anything so it can bump its layout version.
|
||||||
*/
|
*/
|
||||||
final class Tab implements AutoCloseable {
|
final class Tab implements AutoCloseable {
|
||||||
|
// Floating-pane sizing policy: a fraction of the tab's size with a floor so panes stay
|
||||||
|
// usable in small windows, cascaded diagonally per pane, kept off the window edge.
|
||||||
|
private static final double FLOATING_SIZE_FRACTION = 0.58;
|
||||||
|
private static final double FLOATING_MIN_WIDTH = 420.0;
|
||||||
|
private static final double FLOATING_MIN_HEIGHT = 260.0;
|
||||||
|
private static final double FLOATING_CASCADE_OFFSET = 28.0;
|
||||||
|
private static final double FLOATING_EDGE_MARGIN = 12.0;
|
||||||
|
|
||||||
private final AppConfig config;
|
private final AppConfig config;
|
||||||
private final TerminalMetrics metrics;
|
private final TerminalMetrics metrics;
|
||||||
// Notified (on the FX thread) when one of this tab's panes' process exits on its own, so the
|
// Notified (on the FX thread) when one of this tab's panes' process exits on its own, so the
|
||||||
@@ -142,13 +150,15 @@ final class Tab implements AutoCloseable {
|
|||||||
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
|
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
double floatingWidth = Math.max(420, width * 0.58);
|
double floatingWidth = Math.max(FLOATING_MIN_WIDTH, width * FLOATING_SIZE_FRACTION);
|
||||||
double floatingHeight = Math.max(260, availHeight * 0.58);
|
double floatingHeight = Math.max(FLOATING_MIN_HEIGHT, availHeight * FLOATING_SIZE_FRACTION);
|
||||||
for (int i = 0; i < floating.size(); i++) {
|
for (int i = 0; i < floating.size(); i++) {
|
||||||
double offset = i * 28.0;
|
double offset = i * FLOATING_CASCADE_OFFSET;
|
||||||
floating.get(i).bounds(
|
floating.get(i).bounds(
|
||||||
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
|
Math.min(width - floatingWidth - FLOATING_EDGE_MARGIN,
|
||||||
Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset),
|
((width - floatingWidth) / 2.0) + offset),
|
||||||
|
Math.min(height - floatingHeight - FLOATING_EDGE_MARGIN,
|
||||||
|
topInset + ((availHeight - floatingHeight) / 2.0) + offset),
|
||||||
floatingWidth,
|
floatingWidth,
|
||||||
floatingHeight);
|
floatingHeight);
|
||||||
}
|
}
|
||||||
@@ -384,7 +394,9 @@ final class Tab implements AutoCloseable {
|
|||||||
private double[] paneSize(boolean asFloating) {
|
private double[] paneSize(boolean asFloating) {
|
||||||
double availHeight = lastHeight - lastTopInset;
|
double availHeight = lastHeight - lastTopInset;
|
||||||
if (asFloating) {
|
if (asFloating) {
|
||||||
return new double[] {Math.max(420, lastWidth * 0.58), Math.max(260, availHeight * 0.58)};
|
return new double[] {
|
||||||
|
Math.max(FLOATING_MIN_WIDTH, lastWidth * FLOATING_SIZE_FRACTION),
|
||||||
|
Math.max(FLOATING_MIN_HEIGHT, availHeight * FLOATING_SIZE_FRACTION)};
|
||||||
}
|
}
|
||||||
// A new tiled pane joins the row, so each gets 1/(n+1) of the width.
|
// A new tiled pane joins the row, so each gets 1/(n+1) of the width.
|
||||||
return new double[] {lastWidth / (tiled.size() + 1), availHeight};
|
return new double[] {lastWidth / (tiled.size() + 1), availHeight};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import javafx.scene.shape.Shape;
|
|||||||
|
|
||||||
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it,
|
* One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it,
|
||||||
@@ -80,8 +81,8 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
||||||
double widthPx, double heightPx, String workingDirectory) {
|
double widthPx, double heightPx, String workingDirectory) {
|
||||||
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
|
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
|
||||||
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, pane.columns, pane.rows,
|
attachOrShowError(pane, () -> ShellSession.start(config.shell(), config.envOverride(), pane,
|
||||||
workingDirectory, config.closeSignalNumber()));
|
pane.columns, pane.rows, workingDirectory, config.closeSignalNumber()));
|
||||||
return pane;
|
return pane;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,11 +94,23 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
public static TerminalPane createWithCommand(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
public static TerminalPane createWithCommand(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
||||||
double widthPx, double heightPx, String workingDirectory, String command) {
|
double widthPx, double heightPx, String workingDirectory, String command) {
|
||||||
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
|
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
|
||||||
pane.attach(ShellSession.startCommand(config.envOverride(), pane, pane.columns, pane.rows,
|
attachOrShowError(pane, () -> ShellSession.startCommand(config.envOverride(), pane,
|
||||||
workingDirectory, command, config.closeSignalNumber()));
|
pane.columns, pane.rows, workingDirectory, command, config.closeSignalNumber()));
|
||||||
return pane;
|
return pane;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start the pane's process, but never let a spawn failure (e.g. a bad `shell` in config)
|
||||||
|
// propagate and crash window/pane creation. ShellSession has already written the error into
|
||||||
|
// the pane, so the pane opens showing it; with no session attached it is inert (sends are
|
||||||
|
// dropped) and the user closes it with the close-pane key.
|
||||||
|
private static void attachOrShowError(TerminalPane pane, Supplier<ShellSession> start) {
|
||||||
|
try {
|
||||||
|
pane.attach(start.get());
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
System.err.println("jprototerm: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static TerminalPane newPane(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
private static TerminalPane newPane(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
||||||
double widthPx, double heightPx) {
|
double widthPx, double heightPx) {
|
||||||
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
|
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
|
||||||
|
|||||||
@@ -138,8 +138,7 @@ final class TerminalWindow {
|
|||||||
}
|
}
|
||||||
String encoded = KeyEncoder.encode(event);
|
String encoded = KeyEncoder.encode(event);
|
||||||
if (encoded != null) {
|
if (encoded != null) {
|
||||||
compositor.activePane().send(encoded);
|
sendToActivePane(encoded, event);
|
||||||
event.consume();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,15 +149,25 @@ final class TerminalWindow {
|
|||||||
|
|
||||||
String text = event.getCharacter();
|
String text = event.getCharacter();
|
||||||
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
|
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
|
||||||
compositor.activePane().send(text);
|
sendToActivePane(text, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key handlers run on every keystroke, including any that race the window's teardown, so
|
||||||
|
// tolerate the no-pane-left state instead of assuming one exists.
|
||||||
|
private void sendToActivePane(String text, KeyEvent event) {
|
||||||
|
TerminalPane active = compositor.activePane();
|
||||||
|
if (active != null) {
|
||||||
|
active.send(text);
|
||||||
event.consume();
|
event.consume();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void pasteFromClipboard() {
|
private void pasteFromClipboard() {
|
||||||
|
TerminalPane active = compositor.activePane();
|
||||||
Clipboard clipboard = Clipboard.getSystemClipboard();
|
Clipboard clipboard = Clipboard.getSystemClipboard();
|
||||||
if (clipboard.hasString()) {
|
if (active != null && clipboard.hasString()) {
|
||||||
compositor.activePane().paste(clipboard.getString());
|
active.paste(clipboard.getString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,17 +215,22 @@ final class TerminalWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void openScrollbackInEditor() {
|
private void openScrollbackInEditor() {
|
||||||
try {
|
|
||||||
// Capture the active pane's scrollback before opening the floating pane, since that
|
// Capture the active pane's scrollback before opening the floating pane, since that
|
||||||
// makes the new pane active.
|
// makes the new pane active.
|
||||||
|
TerminalPane active = compositor.activePane();
|
||||||
|
if (active == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
||||||
Files.writeString(file, compositor.activePane().scrollbackText());
|
Files.writeString(file, active.scrollbackText());
|
||||||
file.toFile().deleteOnExit();
|
|
||||||
|
|
||||||
// Run the editor as the floating pane's process (via /bin/sh -c) rather than typing the
|
// Run the editor as the floating pane's process (via /bin/sh -c) rather than typing the
|
||||||
// command into an interactive shell. The command runs deterministically from the start
|
// command into an interactive shell. The command runs deterministically from the start
|
||||||
// — no shell startup/rc race — and the pane auto-closes when the editor exits.
|
// — no shell startup/rc race — and the pane auto-closes when the editor exits. The
|
||||||
compositor.openFloatingPane(scrollbackEditorCommand(file));
|
// 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()));
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user