Compare commits
5 Commits
c2ccd056af
...
0fcba6a97d
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fcba6a97d | |||
| a5dca9ca46 | |||
| 96674a3bf5 | |||
| c0ce81f125 | |||
| dcb70243aa |
40
README.md
40
README.md
@@ -62,6 +62,46 @@ gradle run
|
|||||||
|
|
||||||
The Gradle project is the source of truth for the JavaFX build.
|
The Gradle project is the source of truth for the JavaFX build.
|
||||||
|
|
||||||
|
## Daemon (optional, faster launches)
|
||||||
|
|
||||||
|
Cold start pays for JVM + JavaFX + GL/X11 init every time. The optional daemon keeps one JVM
|
||||||
|
(one toolkit) running and hosts every window in it, so a `jprototerm` launch just asks the
|
||||||
|
daemon to open a window — it appears without paying that startup cost again.
|
||||||
|
|
||||||
|
Run it once in the background:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jprototerm --daemon &
|
||||||
|
```
|
||||||
|
|
||||||
|
After that, a bare `jprototerm` connects to the daemon and opens a window in the current
|
||||||
|
directory. If no daemon is running, `jprototerm` falls back to a standalone in-process window
|
||||||
|
(today's behavior), so it always works.
|
||||||
|
|
||||||
|
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):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p ~/.config/systemd/user
|
||||||
|
ln -sf "$(dirname "$(readlink -f "$(command -v jprototerm)")")/../share/systemd/user/jprototerm.service" \
|
||||||
|
~/.config/systemd/user/jprototerm.service
|
||||||
|
systemctl --user enable --now jprototerm.service
|
||||||
|
```
|
||||||
|
|
||||||
|
If the daemon can't reach your display (e.g. `systemctl --user status jprototerm` shows it
|
||||||
|
failing to open a window), import the session variables once and restart it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl --user import-environment DISPLAY XAUTHORITY
|
||||||
|
systemctl --user restart jprototerm.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Closing a window (the WM close button, or the close-pane key on the last pane) tears that
|
||||||
|
window down — its shell processes are signalled with the configured `close_signal` — without
|
||||||
|
affecting other windows or the daemon. Stop the daemon (and all its windows) with
|
||||||
|
`systemctl --user stop jprototerm.service`, or `pkill -f 'jprototerm --daemon'`.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
Configuration is read from:
|
Configuration is read from:
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ columns = 100
|
|||||||
rows = 30
|
rows = 30
|
||||||
max_scrollback = 100000
|
max_scrollback = 100000
|
||||||
shell = "/bin/bash"
|
shell = "/bin/bash"
|
||||||
|
# Signal sent to a pane's shell process when the pane is closed (e.g. ALT+X).
|
||||||
|
# Use SIGKILL to force-kill instead of asking the shell to terminate.
|
||||||
|
close_signal = "SIGTERM"
|
||||||
font_family = "JetBrainsMono Nerd Font"
|
font_family = "JetBrainsMono Nerd Font"
|
||||||
font_size = 15
|
font_size = 15
|
||||||
|
|
||||||
|
|||||||
20
flake.nix
20
flake.nix
@@ -109,6 +109,7 @@
|
|||||||
|
|
||||||
makeWrapper "${pkgs.jdk25}/bin/java" "$out/bin/jprototerm" \
|
makeWrapper "${pkgs.jdk25}/bin/java" "$out/bin/jprototerm" \
|
||||||
--run 'export JPROTOTERM_HOST_LD_LIBRARY_PATH="''${LD_LIBRARY_PATH:-}"' \
|
--run 'export JPROTOTERM_HOST_LD_LIBRARY_PATH="''${LD_LIBRARY_PATH:-}"' \
|
||||||
|
--run 'cdsDir="''${XDG_CACHE_HOME:-$HOME/.cache}/jprototerm"; mkdir -p "$cdsDir"; export JAVA_TOOL_OPTIONS="-XX:+AutoCreateSharedArchive -XX:SharedArchiveFile=$cdsDir/app.jsa ''${JAVA_TOOL_OPTIONS:-}"' \
|
||||||
--add-flags "--enable-native-access=ALL-UNNAMED,javafx.graphics" \
|
--add-flags "--enable-native-access=ALL-UNNAMED,javafx.graphics" \
|
||||||
--add-flags "--module-path $out/share/jprototerm/javafx" \
|
--add-flags "--module-path $out/share/jprototerm/javafx" \
|
||||||
--add-flags "--add-modules javafx.controls,javafx.fxml" \
|
--add-flags "--add-modules javafx.controls,javafx.fxml" \
|
||||||
@@ -119,6 +120,25 @@
|
|||||||
--set JLIBGHOSTTY_LIBRARY "${ghosttyVt}/lib/libghostty-vt.so" \
|
--set JLIBGHOSTTY_LIBRARY "${ghosttyVt}/lib/libghostty-vt.so" \
|
||||||
--set GDK_BACKEND x11
|
--set GDK_BACKEND x11
|
||||||
|
|
||||||
|
# Optional background daemon: one JVM hosts every window, so client launches skip
|
||||||
|
# cold JVM/JavaFX/GL startup. A *user* service tied to graphical-session.target (X11
|
||||||
|
# needs a display, which only exists after login). Enable instructions are in README.
|
||||||
|
mkdir -p "$out/share/systemd/user"
|
||||||
|
cat > "$out/share/systemd/user/jprototerm.service" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=jprototerm terminal daemon
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
After=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=$out/bin/jprototerm --daemon
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=graphical-session.target
|
||||||
|
EOF
|
||||||
|
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public record AppConfig(
|
|||||||
double windowHeight,
|
double windowHeight,
|
||||||
boolean kittyGraphics,
|
boolean kittyGraphics,
|
||||||
String scrollbackEditorCommand,
|
String scrollbackEditorCommand,
|
||||||
|
String closeSignal,
|
||||||
Map<String, String> envOverride,
|
Map<String, String> envOverride,
|
||||||
Map<String, KeyBinding> keybindings
|
Map<String, KeyBinding> keybindings
|
||||||
) {
|
) {
|
||||||
@@ -71,6 +72,7 @@ public record AppConfig(
|
|||||||
doubleValue(document, "window.height", defaults.windowHeight),
|
doubleValue(document, "window.height", defaults.windowHeight),
|
||||||
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),
|
||||||
|
closeSignalValue(document, defaults.closeSignal),
|
||||||
envOverride(document, defaults.envOverride),
|
envOverride(document, defaults.envOverride),
|
||||||
keybindings(document, defaults)
|
keybindings(document, defaults)
|
||||||
);
|
);
|
||||||
@@ -92,6 +94,7 @@ public record AppConfig(
|
|||||||
760.0,
|
760.0,
|
||||||
true,
|
true,
|
||||||
defaultScrollbackEditorCommand(),
|
defaultScrollbackEditorCommand(),
|
||||||
|
"SIGTERM",
|
||||||
Map.of(),
|
Map.of(),
|
||||||
Map.ofEntries(
|
Map.ofEntries(
|
||||||
Map.entry("navigate_left", KeyBinding.parse("ALT+H")),
|
Map.entry("navigate_left", KeyBinding.parse("ALT+H")),
|
||||||
@@ -125,11 +128,22 @@ public record AppConfig(
|
|||||||
windowHeight,
|
windowHeight,
|
||||||
kittyGraphics,
|
kittyGraphics,
|
||||||
scrollbackEditorCommand,
|
scrollbackEditorCommand,
|
||||||
|
closeSignal,
|
||||||
envOverride,
|
envOverride,
|
||||||
keybindings
|
keybindings
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link #closeSignal} as a Linux signal number, sent to a pane's shell process when the
|
||||||
|
* pane is closed (e.g. via the close-pane key). Falls back to SIGTERM (15) if the configured
|
||||||
|
* name is somehow unresolvable.
|
||||||
|
*/
|
||||||
|
public int closeSignalNumber() {
|
||||||
|
int number = LinuxPty.signalNumber(closeSignal);
|
||||||
|
return number < 0 ? 15 : number;
|
||||||
|
}
|
||||||
|
|
||||||
public void save() {
|
public void save() {
|
||||||
save(configPath(), this);
|
save(configPath(), this);
|
||||||
}
|
}
|
||||||
@@ -156,6 +170,23 @@ public record AppConfig(
|
|||||||
return editor.trim() + " {file}";
|
return editor.trim() + " {file}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads {@code terminal.close_signal}, normalising it to a canonical {@code SIG*} name. An
|
||||||
|
* unknown or unset value keeps {@code fallback} so a typo can't leave a pane unkillable.
|
||||||
|
*/
|
||||||
|
private static String closeSignalValue(TomlTable table, String fallback) {
|
||||||
|
String value = stringValue(table, "terminal.close_signal", null);
|
||||||
|
if (value == null) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (LinuxPty.signalNumber(value) < 0) {
|
||||||
|
System.err.println("Unknown terminal.close_signal '" + value + "', using " + fallback);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
String normalized = value.trim().toUpperCase(java.util.Locale.ROOT);
|
||||||
|
return normalized.startsWith("SIG") ? normalized : "SIG" + normalized;
|
||||||
|
}
|
||||||
|
|
||||||
private static Map<String, KeyBinding> keybindings(TomlTable table, AppConfig defaults) {
|
private static Map<String, KeyBinding> keybindings(TomlTable table, AppConfig defaults) {
|
||||||
Map<String, KeyBinding> parsed = new LinkedHashMap<>();
|
Map<String, KeyBinding> parsed = new LinkedHashMap<>();
|
||||||
for (String key : KEYBINDING_KEYS) {
|
for (String key : KEYBINDING_KEYS) {
|
||||||
@@ -193,6 +224,7 @@ public record AppConfig(
|
|||||||
builder.append("rows = ").append(rows).append('\n');
|
builder.append("rows = ").append(rows).append('\n');
|
||||||
builder.append("max_scrollback = ").append(maxScrollback).append('\n');
|
builder.append("max_scrollback = ").append(maxScrollback).append('\n');
|
||||||
builder.append("shell = ").append(quotedList(shell)).append('\n');
|
builder.append("shell = ").append(quotedList(shell)).append('\n');
|
||||||
|
builder.append("close_signal = ").append(quoted(closeSignal)).append('\n');
|
||||||
builder.append("font_family = ").append(quoted(fontFamily)).append('\n');
|
builder.append("font_family = ").append(quoted(fontFamily)).append('\n');
|
||||||
builder.append("font_size = ").append(trimDouble(fontSize)).append("\n\n");
|
builder.append("font_size = ").append(trimDouble(fontSize)).append("\n\n");
|
||||||
builder.append("[window]\n");
|
builder.append("[window]\n");
|
||||||
|
|||||||
@@ -65,9 +65,18 @@ public final class Compositor {
|
|||||||
private Runnable onEmpty = () -> {};
|
private Runnable onEmpty = () -> {};
|
||||||
|
|
||||||
public Compositor(AppConfig config, TerminalMetrics metrics) {
|
public Compositor(AppConfig config, TerminalMetrics metrics) {
|
||||||
|
this(config, metrics, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a compositor whose first tab's first pane starts in {@code workingDirectory} (e.g. the
|
||||||
|
* cwd a client passed when asking the daemon to open this window), or the user's home when
|
||||||
|
* {@code null}.
|
||||||
|
*/
|
||||||
|
public Compositor(AppConfig config, TerminalMetrics metrics, String workingDirectory) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.metrics = metrics;
|
this.metrics = metrics;
|
||||||
tabs.add(new Tab(config, metrics, this::closePane));
|
tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
|
||||||
canvas.setFocusTraversable(true);
|
canvas.setFocusTraversable(true);
|
||||||
canvas.setOnMousePressed(this::handleMousePressed);
|
canvas.setOnMousePressed(this::handleMousePressed);
|
||||||
canvas.setOnMouseReleased(this::handleMouseReleased);
|
canvas.setOnMouseReleased(this::handleMouseReleased);
|
||||||
@@ -236,6 +245,18 @@ public final class Compositor {
|
|||||||
tabs.clear();
|
tabs.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals and reaps every pane's shell process across all tabs, without tearing down render
|
||||||
|
* state. Intended for a JVM shutdown hook (SIGTERM/SIGINT/SIGHUP), so child shells get the
|
||||||
|
* configured close signal instead of being orphaned when jprototerm itself is killed. Safe to
|
||||||
|
* call off the FX thread and idempotent; see {@link TerminalPane#terminateSession()}.
|
||||||
|
*/
|
||||||
|
public void terminateSessions() {
|
||||||
|
for (Tab tab : List.copyOf(tabs)) {
|
||||||
|
tab.terminateSessions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Tab currentTab() {
|
private Tab currentTab() {
|
||||||
return tabs.get(currentTabIndex);
|
return tabs.get(currentTabIndex);
|
||||||
}
|
}
|
||||||
|
|||||||
146
src/main/java/com/gregor/jprototerm/Daemon.java
Normal file
146
src/main/java/com/gregor/jprototerm/Daemon.java
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.StandardProtocolFamily;
|
||||||
|
import java.net.UnixDomainSocketAddress;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.channels.ServerSocketChannel;
|
||||||
|
import java.nio.channels.SocketChannel;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.attribute.PosixFilePermissions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-instance server and its thin client, over a per-user Unix domain socket. The server hosts
|
||||||
|
* every window in one JVM (see {@link WindowManager}); a client invocation just asks it to open a
|
||||||
|
* window in the client's working directory and exits, so the window appears without paying cold
|
||||||
|
* JVM/JavaFX/GL startup.
|
||||||
|
*
|
||||||
|
* <p>Protocol is deliberately trivial: the client writes one UTF-8 line — the absolute working
|
||||||
|
* directory — and the server replies {@code OK\n}. The socket lives under {@code XDG_RUNTIME_DIR}
|
||||||
|
* (mode 0700), so only the owning user can connect.
|
||||||
|
*/
|
||||||
|
public final class Daemon {
|
||||||
|
private Daemon() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs the server: brings up the toolkit, binds the socket, and serves window-open requests. */
|
||||||
|
public static void run() {
|
||||||
|
Path socket = socketPath();
|
||||||
|
try {
|
||||||
|
Files.createDirectories(socket.getParent());
|
||||||
|
trySecureDir(socket.getParent());
|
||||||
|
} catch (IOException ex) {
|
||||||
|
System.err.println("jprototerm: cannot create socket dir " + socket.getParent() + ": " + ex.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowManager manager = WindowManager.start(WindowManager.Mode.DAEMON);
|
||||||
|
|
||||||
|
try (ServerSocketChannel server = bind(socket)) {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
handle(server.accept(), manager);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
System.err.println("jprototerm: connection error: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException ex) {
|
||||||
|
System.err.println("jprototerm: daemon socket error: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client side: connect to a running daemon and ask it to open a window in {@code workingDirectory}.
|
||||||
|
* Returns {@code true} if the daemon handled it, {@code false} if none is reachable (the caller
|
||||||
|
* then falls back to a standalone in-process window).
|
||||||
|
*/
|
||||||
|
public static boolean tryClient(String workingDirectory) {
|
||||||
|
Path socket = socketPath();
|
||||||
|
if (!Files.exists(socket)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try (SocketChannel channel = SocketChannel.open(UnixDomainSocketAddress.of(socket))) {
|
||||||
|
channel.write(ByteBuffer.wrap((workingDirectory + "\n").getBytes(StandardCharsets.UTF_8)));
|
||||||
|
// Best-effort wait for the ack so we don't race ahead of the window opening.
|
||||||
|
channel.read(ByteBuffer.allocate(16));
|
||||||
|
return true;
|
||||||
|
} catch (IOException ex) {
|
||||||
|
return false; // no daemon, or a stale socket file — fall back to standalone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServerSocketChannel bind(Path socket) throws IOException {
|
||||||
|
UnixDomainSocketAddress address = UnixDomainSocketAddress.of(socket);
|
||||||
|
ServerSocketChannel channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX);
|
||||||
|
try {
|
||||||
|
channel.bind(address);
|
||||||
|
return channel;
|
||||||
|
} catch (IOException firstTry) {
|
||||||
|
// The path is taken. If a live daemon answers, this invocation lost the race; otherwise
|
||||||
|
// it's a stale socket from a crashed daemon, so remove it and rebind.
|
||||||
|
if (tryClient0(socket)) {
|
||||||
|
channel.close();
|
||||||
|
System.err.println("jprototerm: a daemon is already running");
|
||||||
|
System.exit(0);
|
||||||
|
}
|
||||||
|
Files.deleteIfExists(socket);
|
||||||
|
channel.bind(address);
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bare connect probe used by {@link #bind} to tell a live daemon from a stale socket file. */
|
||||||
|
private static boolean tryClient0(Path socket) {
|
||||||
|
try (SocketChannel channel = SocketChannel.open(UnixDomainSocketAddress.of(socket))) {
|
||||||
|
return true;
|
||||||
|
} catch (IOException ex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void handle(SocketChannel connection, WindowManager manager) throws IOException {
|
||||||
|
try (connection) {
|
||||||
|
String workingDirectory = readLine(connection);
|
||||||
|
manager.openWindow(workingDirectory == null || workingDirectory.isBlank()
|
||||||
|
? null
|
||||||
|
: workingDirectory.trim());
|
||||||
|
connection.write(ByteBuffer.wrap("OK\n".getBytes(StandardCharsets.UTF_8)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readLine(SocketChannel channel) throws IOException {
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
ByteBuffer buffer = ByteBuffer.allocate(4096);
|
||||||
|
while (channel.read(buffer) != -1) {
|
||||||
|
buffer.flip();
|
||||||
|
while (buffer.hasRemaining()) {
|
||||||
|
byte b = buffer.get();
|
||||||
|
if (b == '\n') {
|
||||||
|
return out.toString(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
out.write(b);
|
||||||
|
}
|
||||||
|
buffer.clear();
|
||||||
|
}
|
||||||
|
return out.size() == 0 ? null : out.toString(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path socketPath() {
|
||||||
|
String runtimeDir = System.getenv("XDG_RUNTIME_DIR");
|
||||||
|
Path dir = runtimeDir != null && !runtimeDir.isBlank()
|
||||||
|
? Path.of(runtimeDir, "jprototerm")
|
||||||
|
: Path.of("/tmp", "jprototerm-" + System.getProperty("user.name", "user"));
|
||||||
|
return dir.resolve("daemon.sock");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void trySecureDir(Path dir) {
|
||||||
|
try {
|
||||||
|
Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwx------"));
|
||||||
|
} catch (IOException | UnsupportedOperationException ignored) {
|
||||||
|
// Best effort: XDG_RUNTIME_DIR is already user-private; the /tmp fallback we try to lock.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,7 +63,10 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
private static final long TIOCSWINSZ = 0x5414L;
|
private static final long TIOCSWINSZ = 0x5414L;
|
||||||
private static final short POSIX_SPAWN_SETSID = 0x80;
|
private static final short POSIX_SPAWN_SETSID = 0x80;
|
||||||
private static final int SIGHUP = 1;
|
private static final int SIGHUP = 1;
|
||||||
|
private static final int SIGINT = 2;
|
||||||
|
private static final int SIGQUIT = 3;
|
||||||
private static final int SIGKILL = 9;
|
private static final int SIGKILL = 9;
|
||||||
|
private static final int SIGTERM = 15;
|
||||||
private static final int WNOHANG = 1;
|
private static final int WNOHANG = 1;
|
||||||
|
|
||||||
// struct winsize { unsigned short ws_row, ws_col, ws_xpixel, ws_ypixel; }
|
// struct winsize { unsigned short ws_row, ws_col, ws_xpixel, ws_ypixel; }
|
||||||
@@ -105,11 +108,36 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
private final Object writeLock = new Object();
|
private final Object writeLock = new Object();
|
||||||
private final int masterFd;
|
private final int masterFd;
|
||||||
private final int pid;
|
private final int pid;
|
||||||
|
private final int closeSignal;
|
||||||
private volatile boolean closed;
|
private volatile boolean closed;
|
||||||
|
|
||||||
private LinuxPty(int masterFd, int pid) {
|
private LinuxPty(int masterFd, int pid, int closeSignal) {
|
||||||
this.masterFd = masterFd;
|
this.masterFd = masterFd;
|
||||||
this.pid = pid;
|
this.pid = pid;
|
||||||
|
this.closeSignal = closeSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a signal name (e.g. {@code "SIGTERM"}, {@code "TERM"}, {@code "SIGKILL"}) to its
|
||||||
|
* Linux signal number, or {@code -1} if the name is not one we recognise. Case-insensitive and
|
||||||
|
* tolerant of a missing {@code SIG} prefix.
|
||||||
|
*/
|
||||||
|
public static int signalNumber(String name) {
|
||||||
|
if (name == null) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
String normalized = name.trim().toUpperCase(java.util.Locale.ROOT);
|
||||||
|
if (normalized.startsWith("SIG")) {
|
||||||
|
normalized = normalized.substring(3);
|
||||||
|
}
|
||||||
|
return switch (normalized) {
|
||||||
|
case "HUP" -> SIGHUP;
|
||||||
|
case "INT" -> SIGINT;
|
||||||
|
case "QUIT" -> SIGQUIT;
|
||||||
|
case "KILL" -> SIGKILL;
|
||||||
|
case "TERM" -> SIGTERM;
|
||||||
|
default -> -1;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,8 +146,10 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
* @param argv command and arguments (e.g. {@code {"/bin/zsh", "-i"}})
|
* @param argv command and arguments (e.g. {@code {"/bin/zsh", "-i"}})
|
||||||
* @param environment environment for the child, as KEY=VALUE pairs
|
* @param environment environment for the child, as KEY=VALUE pairs
|
||||||
* @param workingDirectory directory the child starts in, or {@code null} to inherit
|
* @param workingDirectory directory the child starts in, or {@code null} to inherit
|
||||||
|
* @param closeSignal signal number sent to the child on {@link #close()} (e.g. SIGTERM)
|
||||||
*/
|
*/
|
||||||
public static LinuxPty spawn(String[] argv, Map<String, String> environment, String workingDirectory) {
|
public static LinuxPty spawn(String[] argv, Map<String, String> environment, String workingDirectory,
|
||||||
|
int closeSignal) {
|
||||||
Arena setup = Arena.ofConfined();
|
Arena setup = Arena.ofConfined();
|
||||||
try {
|
try {
|
||||||
int master = check(callInt(POSIX_OPENPT, O_RDWR | O_NOCTTY), "posix_openpt");
|
int master = check(callInt(POSIX_OPENPT, O_RDWR | O_NOCTTY), "posix_openpt");
|
||||||
@@ -158,7 +188,7 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
if (rc != 0) {
|
if (rc != 0) {
|
||||||
throw new IllegalStateException("posix_spawnp failed for " + argv[0] + " (rc=" + rc + ")");
|
throw new IllegalStateException("posix_spawnp failed for " + argv[0] + " (rc=" + rc + ")");
|
||||||
}
|
}
|
||||||
return new LinuxPty(master, pidOut.get(C_INT, 0));
|
return new LinuxPty(master, pidOut.get(C_INT, 0), closeSignal);
|
||||||
} finally {
|
} finally {
|
||||||
callInt(ATTR_DESTROY, attr);
|
callInt(ATTR_DESTROY, attr);
|
||||||
callInt(FA_DESTROY, actions);
|
callInt(FA_DESTROY, actions);
|
||||||
@@ -249,7 +279,7 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closed = true;
|
closed = true;
|
||||||
callKill(pid, SIGHUP);
|
callKill(pid, closeSignal);
|
||||||
callInt(CLOSE, masterFd);
|
callInt(CLOSE, masterFd);
|
||||||
reap();
|
reap();
|
||||||
arena.close();
|
arena.close();
|
||||||
|
|||||||
@@ -1,251 +1,31 @@
|
|||||||
package com.gregor.jprototerm;
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
import javafx.animation.AnimationTimer;
|
/**
|
||||||
import javafx.application.Application;
|
* Entry point and mode dispatch. A bare invocation is a thin client: it hands the request to a
|
||||||
import javafx.application.Platform;
|
* running {@link Daemon}, or, if none is reachable, opens a single standalone window in this process
|
||||||
import javafx.geometry.Rectangle2D;
|
* (today's behavior). {@code --daemon} runs the long-lived server that hosts every window in one
|
||||||
import javafx.scene.Scene;
|
* JVM, so client launches skip cold JVM/JavaFX/GL startup.
|
||||||
import javafx.scene.control.ButtonType;
|
*/
|
||||||
import javafx.scene.control.ComboBox;
|
public final class Main {
|
||||||
import javafx.scene.control.Dialog;
|
private Main() {
|
||||||
import javafx.scene.control.Label;
|
|
||||||
import javafx.scene.control.Spinner;
|
|
||||||
import javafx.scene.control.SpinnerValueFactory;
|
|
||||||
import javafx.scene.input.Clipboard;
|
|
||||||
import javafx.scene.input.KeyEvent;
|
|
||||||
import javafx.scene.layout.GridPane;
|
|
||||||
import javafx.scene.layout.StackPane;
|
|
||||||
import javafx.scene.text.Font;
|
|
||||||
import javafx.stage.Screen;
|
|
||||||
import javafx.stage.Stage;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public final class Main extends Application {
|
|
||||||
private Compositor compositor;
|
|
||||||
private TerminalMetrics metrics;
|
|
||||||
private AppConfig config;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void start(Stage stage) {
|
|
||||||
config = AppConfig.load();
|
|
||||||
|
|
||||||
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
|
|
||||||
compositor = new Compositor(config, metrics);
|
|
||||||
// When the last pane closes — whether via the close-pane key or because a pane's process
|
|
||||||
// exited on its own — tear down and quit.
|
|
||||||
compositor.setOnEmpty(() -> {
|
|
||||||
compositor.close();
|
|
||||||
Platform.exit();
|
|
||||||
});
|
|
||||||
|
|
||||||
StackPane root = new StackPane(compositor.canvas(), compositor.imageOverlay());
|
|
||||||
compositor.canvas().widthProperty().bind(root.widthProperty());
|
|
||||||
compositor.canvas().heightProperty().bind(root.heightProperty());
|
|
||||||
|
|
||||||
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
|
|
||||||
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
|
|
||||||
scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event));
|
|
||||||
|
|
||||||
new AnimationTimer() {
|
|
||||||
@Override
|
|
||||||
public void handle(long now) {
|
|
||||||
compositor.render();
|
|
||||||
}
|
|
||||||
}.start();
|
|
||||||
|
|
||||||
stage.setTitle("jprototerm");
|
|
||||||
stage.setScene(scene);
|
|
||||||
stage.setOnCloseRequest(event -> {
|
|
||||||
compositor.close();
|
|
||||||
});
|
|
||||||
// JavaFX centres a new stage on the primary screen; on X11 there's no "focused monitor"
|
|
||||||
// to honour, so place it on the screen under the mouse pointer instead.
|
|
||||||
centreOnActiveScreen(stage, config.windowWidth(), config.windowHeight());
|
|
||||||
stage.show();
|
|
||||||
// Ask the window manager to raise and focus the new window so the user can type right
|
|
||||||
// away; the canvas requestFocus() below only routes events within the scene.
|
|
||||||
stage.toFront();
|
|
||||||
stage.requestFocus();
|
|
||||||
compositor.canvas().requestFocus();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Centre the stage within the screen the mouse pointer is on (the best proxy for the
|
|
||||||
// "active" monitor on X11, which exposes no focused-monitor concept to JavaFX).
|
|
||||||
private static void centreOnActiveScreen(Stage stage, double width, double height) {
|
|
||||||
Rectangle2D bounds = activeScreen().getVisualBounds();
|
|
||||||
stage.setX(bounds.getMinX() + ((bounds.getWidth() - width) / 2.0));
|
|
||||||
stage.setY(bounds.getMinY() + ((bounds.getHeight() - height) / 2.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Screen activeScreen() {
|
|
||||||
int[] at = X11Pointer.query();
|
|
||||||
if (at != null) {
|
|
||||||
// libX11 and JavaFX share a coordinate space on the X11 virtual screen.
|
|
||||||
List<Screen> screens = Screen.getScreensForRectangle(at[0], at[1], 1.0, 1.0);
|
|
||||||
if (!screens.isEmpty()) {
|
|
||||||
return screens.get(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Screen.getPrimary();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handlePressed(KeyEvent event) {
|
|
||||||
if (config.keybindings().get("navigate_left").matches(event)) {
|
|
||||||
compositor.navigate(Direction.LEFT);
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("navigate_down").matches(event)) {
|
|
||||||
compositor.navigate(Direction.DOWN);
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("navigate_up").matches(event)) {
|
|
||||||
compositor.navigate(Direction.UP);
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("navigate_right").matches(event)) {
|
|
||||||
compositor.navigate(Direction.RIGHT);
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("toggle_floating").matches(event)) {
|
|
||||||
compositor.toggleFloating();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("new_pane").matches(event)) {
|
|
||||||
compositor.createPane();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("next_floating").matches(event)) {
|
|
||||||
compositor.nextFloatingPane();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("promote_floating").matches(event)) {
|
|
||||||
compositor.promoteActiveFloating();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("close_pane").matches(event)) {
|
|
||||||
// Closing the last pane quits the app, via the compositor's onEmpty hook.
|
|
||||||
compositor.closeActivePane();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("new_tab").matches(event)) {
|
|
||||||
compositor.newTab();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("previous_tab").matches(event)) {
|
|
||||||
compositor.previousTab();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("next_tab").matches(event)) {
|
|
||||||
compositor.nextTab();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("open_font_selector").matches(event)) {
|
|
||||||
openFontSelector();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("open_scrollback").matches(event)) {
|
|
||||||
openScrollbackInEditor();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("paste").matches(event)) {
|
|
||||||
pasteFromClipboard();
|
|
||||||
event.consume();
|
|
||||||
} else {
|
|
||||||
String encoded = KeyEncoder.encode(event);
|
|
||||||
if (encoded != null) {
|
|
||||||
compositor.activePane().send(encoded);
|
|
||||||
event.consume();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleTyped(KeyEvent event) {
|
|
||||||
if (event.isAltDown() || event.isControlDown() || event.isMetaDown()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String text = event.getCharacter();
|
|
||||||
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
|
|
||||||
compositor.activePane().send(text);
|
|
||||||
event.consume();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void pasteFromClipboard() {
|
|
||||||
Clipboard clipboard = Clipboard.getSystemClipboard();
|
|
||||||
if (clipboard.hasString()) {
|
|
||||||
compositor.activePane().paste(clipboard.getString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void openFontSelector() {
|
|
||||||
Dialog<ButtonType> dialog = new Dialog<>();
|
|
||||||
dialog.setTitle("Font");
|
|
||||||
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
|
|
||||||
|
|
||||||
ComboBox<String> family = new ComboBox<>();
|
|
||||||
family.getItems().setAll(Font.getFamilies());
|
|
||||||
family.setEditable(true);
|
|
||||||
family.setMaxWidth(Double.MAX_VALUE);
|
|
||||||
family.setValue(config.fontFamily());
|
|
||||||
|
|
||||||
Spinner<Double> size = new Spinner<>();
|
|
||||||
size.setEditable(true);
|
|
||||||
size.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(6.0, 48.0, config.fontSize(), 0.5));
|
|
||||||
|
|
||||||
GridPane content = new GridPane();
|
|
||||||
content.setHgap(10.0);
|
|
||||||
content.setVgap(10.0);
|
|
||||||
content.add(new Label("Family"), 0, 0);
|
|
||||||
content.add(family, 1, 0);
|
|
||||||
content.add(new Label("Size"), 0, 1);
|
|
||||||
content.add(size, 1, 1);
|
|
||||||
dialog.getDialogPane().setContent(content);
|
|
||||||
|
|
||||||
dialog.showAndWait()
|
|
||||||
.filter(button -> button == ButtonType.OK)
|
|
||||||
.ifPresent(ignored -> {
|
|
||||||
String selectedFamily = family.getEditor().getText();
|
|
||||||
if (selectedFamily == null || selectedFamily.isBlank()) {
|
|
||||||
selectedFamily = family.getValue();
|
|
||||||
}
|
|
||||||
if (selectedFamily == null || selectedFamily.isBlank()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
double selectedSize = size.getValue();
|
|
||||||
config = config.withFont(selectedFamily.trim(), selectedSize);
|
|
||||||
config.save();
|
|
||||||
compositor.setFont(config.fontFamily(), config.fontSize());
|
|
||||||
compositor.canvas().requestFocus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void openScrollbackInEditor() {
|
|
||||||
try {
|
|
||||||
// Capture the active pane's scrollback before opening the floating pane, since that
|
|
||||||
// makes the new pane active.
|
|
||||||
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
|
||||||
Files.writeString(file, compositor.activePane().scrollbackText());
|
|
||||||
file.toFile().deleteOnExit();
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// — no shell startup/rc race — and the pane auto-closes when the editor exits.
|
|
||||||
compositor.openFloatingPane(scrollbackEditorCommand(file));
|
|
||||||
} catch (IOException ex) {
|
|
||||||
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String scrollbackEditorCommand(Path file) {
|
|
||||||
String quotedFile = shellQuote(file.toString());
|
|
||||||
String command = config.scrollbackEditorCommand();
|
|
||||||
if (command == null || command.isBlank()) {
|
|
||||||
command = "vi {file}";
|
|
||||||
}
|
|
||||||
if (command.contains("{file}")) {
|
|
||||||
return command.replace("{file}", quotedFile);
|
|
||||||
}
|
|
||||||
return command + " " + quotedFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String shellQuote(String value) {
|
|
||||||
return "'" + value.replace("'", "'\"'\"'") + "'";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
// 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"));
|
System.setProperty("prism.order", System.getProperty("prism.order", "es2,sw"));
|
||||||
launch(Main.class, args);
|
|
||||||
|
for (String arg : args) {
|
||||||
|
if (arg.equals("--daemon")) {
|
||||||
|
Daemon.run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String workingDirectory = System.getProperty("user.dir");
|
||||||
|
if (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.
|
||||||
|
WindowManager.start(WindowManager.Mode.STANDALONE).openWindow(workingDirectory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ public final class ShellSession implements AutoCloseable {
|
|||||||
* config, not assumed here.
|
* config, not assumed here.
|
||||||
*/
|
*/
|
||||||
public static ShellSession start(List<String> shellCommand, Map<String, String> envOverride, TerminalPane pane,
|
public static ShellSession start(List<String> shellCommand, Map<String, String> envOverride, TerminalPane pane,
|
||||||
int columns, int rows, String workingDirectory) {
|
int columns, int rows, String workingDirectory, int closeSignal) {
|
||||||
try {
|
try {
|
||||||
return spawn(shellCommand.toArray(new String[0]), envOverride, columns, rows, workingDirectory);
|
return spawn(shellCommand.toArray(new String[0]), envOverride, columns, rows, workingDirectory,
|
||||||
|
closeSignal);
|
||||||
} catch (RuntimeException ex) {
|
} catch (RuntimeException ex) {
|
||||||
pane.write("failed to start shell: " + ex.getMessage() + "\r\n");
|
pane.write("failed to start shell: " + ex.getMessage() + "\r\n");
|
||||||
throw new IllegalStateException("Could not start shell " + String.join(" ", shellCommand), ex);
|
throw new IllegalStateException("Could not start shell " + String.join(" ", shellCommand), ex);
|
||||||
@@ -45,9 +46,10 @@ public final class ShellSession implements AutoCloseable {
|
|||||||
* flags. {@code command} must not be null.
|
* flags. {@code command} must not be null.
|
||||||
*/
|
*/
|
||||||
public static ShellSession startCommand(Map<String, String> envOverride, TerminalPane pane,
|
public static ShellSession startCommand(Map<String, String> envOverride, TerminalPane pane,
|
||||||
int columns, int rows, String workingDirectory, String command) {
|
int columns, int rows, String workingDirectory, String command, int closeSignal) {
|
||||||
try {
|
try {
|
||||||
return spawn(new String[] {"/bin/sh", "-c", command}, envOverride, columns, rows, workingDirectory);
|
return spawn(new String[] {"/bin/sh", "-c", command}, envOverride, columns, rows, workingDirectory,
|
||||||
|
closeSignal);
|
||||||
} catch (RuntimeException ex) {
|
} catch (RuntimeException ex) {
|
||||||
pane.write("failed to run command: " + ex.getMessage() + "\r\n");
|
pane.write("failed to run command: " + ex.getMessage() + "\r\n");
|
||||||
throw new IllegalStateException("Could not run command: " + command, ex);
|
throw new IllegalStateException("Could not run command: " + command, ex);
|
||||||
@@ -55,7 +57,7 @@ public final class ShellSession implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static ShellSession spawn(String[] argv, Map<String, String> envOverride,
|
private static ShellSession spawn(String[] argv, Map<String, String> envOverride,
|
||||||
int columns, int rows, String workingDirectory) {
|
int columns, int rows, String workingDirectory, int closeSignal) {
|
||||||
Map<String, String> environment = new HashMap<>(System.getenv());
|
Map<String, String> environment = new HashMap<>(System.getenv());
|
||||||
environment.put("TERM", "xterm-kitty");
|
environment.put("TERM", "xterm-kitty");
|
||||||
environment.put("COLORTERM", "truecolor");
|
environment.put("COLORTERM", "truecolor");
|
||||||
@@ -65,7 +67,8 @@ public final class ShellSession implements AutoCloseable {
|
|||||||
LinuxPty pty = LinuxPty.spawn(
|
LinuxPty pty = LinuxPty.spawn(
|
||||||
argv,
|
argv,
|
||||||
environment,
|
environment,
|
||||||
workingDirectory != null ? workingDirectory : System.getProperty("user.home"));
|
workingDirectory != null ? workingDirectory : System.getProperty("user.home"),
|
||||||
|
closeSignal);
|
||||||
ShellSession session = new ShellSession(pty);
|
ShellSession session = new ShellSession(pty);
|
||||||
session.resize(columns, rows);
|
session.resize(columns, rows);
|
||||||
return session;
|
return session;
|
||||||
|
|||||||
48
src/main/java/com/gregor/jprototerm/StartupTiming.java
Normal file
48
src/main/java/com/gregor/jprototerm/StartupTiming.java
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import java.lang.management.ManagementFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opt-in startup phase timing, enabled with {@code -Djprototerm.timing=true} (e.g. via
|
||||||
|
* {@code JAVA_TOOL_OPTIONS}); otherwise every method is a cheap no-op and prints nothing.
|
||||||
|
*
|
||||||
|
* <p>Each {@link #mark(String)} prints one line to stderr with the time since the previous mark and
|
||||||
|
* the total since JVM start, so a cold launch breaks down into its phases — toolkit/GL init vs
|
||||||
|
* config load vs font loading vs first frame. The anchor is the JVM's own start time (the closest
|
||||||
|
* proxy we have to "process start"), so the first mark includes JVM bootstrap and JavaFX toolkit
|
||||||
|
* init, which is usually the dominant cost.
|
||||||
|
*/
|
||||||
|
final class StartupTiming {
|
||||||
|
private static final boolean ENABLED = Boolean.getBoolean("jprototerm.timing");
|
||||||
|
// Epoch millis; getStartTime() is the JVM's start, the earliest timestamp we can anchor to.
|
||||||
|
private static final long JVM_START_MILLIS = ManagementFactory.getRuntimeMXBean().getStartTime();
|
||||||
|
private static long lastMillis = -1;
|
||||||
|
private static boolean firstFrameSeen;
|
||||||
|
|
||||||
|
private StartupTiming() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records a phase boundary, printing the delta since the previous mark and since JVM start. */
|
||||||
|
static void mark(String phase) {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long sinceStart = now - JVM_START_MILLIS;
|
||||||
|
long sinceLast = lastMillis < 0 ? sinceStart : now - lastMillis;
|
||||||
|
lastMillis = now;
|
||||||
|
System.err.printf("[timing] %-22s +%5d ms (%5d ms since JVM start)%n", phase, sinceLast, sinceStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
static void firstFrame() {
|
||||||
|
if (!ENABLED || firstFrameSeen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
firstFrameSeen = true;
|
||||||
|
mark("first frame");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -423,4 +423,14 @@ final class Tab implements AutoCloseable {
|
|||||||
tiled.clear();
|
tiled.clear();
|
||||||
floating.clear();
|
floating.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals and reaps every pane's shell process without tearing down render state. Safe to call
|
||||||
|
* off the FX thread (see {@link TerminalPane#terminateSession()}); iterates snapshots so a
|
||||||
|
* concurrent close on the FX thread can't trigger a {@link java.util.ConcurrentModificationException}.
|
||||||
|
*/
|
||||||
|
public void terminateSessions() {
|
||||||
|
List.copyOf(tiled).forEach(TerminalPane::terminateSession);
|
||||||
|
List.copyOf(floating).forEach(TerminalPane::terminateSession);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
// tracking meaningful: update() accumulates dirty since the last resetDirty().
|
// tracking meaningful: update() accumulates dirty since the last resetDirty().
|
||||||
private final RenderState renderState = new RenderState();
|
private final RenderState renderState = new RenderState();
|
||||||
private RenderStateSnapshot cachedSnapshot;
|
private RenderStateSnapshot cachedSnapshot;
|
||||||
private 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. Set by the Tab that creates the pane; null until then.
|
||||||
private Runnable onExit;
|
private Runnable onExit;
|
||||||
@@ -81,7 +81,7 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
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,
|
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, pane.columns, pane.rows,
|
||||||
workingDirectory));
|
workingDirectory, config.closeSignalNumber()));
|
||||||
return pane;
|
return pane;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
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,
|
pane.attach(ShellSession.startCommand(config.envOverride(), pane, pane.columns, pane.rows,
|
||||||
workingDirectory, command));
|
workingDirectory, command, config.closeSignalNumber()));
|
||||||
return pane;
|
return pane;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,4 +380,17 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
renderState.close();
|
renderState.close();
|
||||||
terminal.close();
|
terminal.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals and reaps just the shell process, leaving the render/native state untouched. Unlike
|
||||||
|
* {@link #close()} this is safe to call off the FX thread — notably from a JVM shutdown hook,
|
||||||
|
* which runs concurrently with the live render loop — because it only touches the pty (a child
|
||||||
|
* process and fd), not ghostty's terminal handles. Idempotent; the OS reclaims the rest on exit.
|
||||||
|
*/
|
||||||
|
public void terminateSession() {
|
||||||
|
ShellSession current = session;
|
||||||
|
if (current != null) {
|
||||||
|
current.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
279
src/main/java/com/gregor/jprototerm/TerminalWindow.java
Normal file
279
src/main/java/com/gregor/jprototerm/TerminalWindow.java
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import javafx.animation.AnimationTimer;
|
||||||
|
import javafx.geometry.Rectangle2D;
|
||||||
|
import javafx.scene.Scene;
|
||||||
|
import javafx.scene.control.ButtonType;
|
||||||
|
import javafx.scene.control.ComboBox;
|
||||||
|
import javafx.scene.control.Dialog;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.Spinner;
|
||||||
|
import javafx.scene.control.SpinnerValueFactory;
|
||||||
|
import javafx.scene.input.Clipboard;
|
||||||
|
import javafx.scene.input.KeyEvent;
|
||||||
|
import javafx.scene.layout.GridPane;
|
||||||
|
import javafx.scene.layout.StackPane;
|
||||||
|
import javafx.scene.text.Font;
|
||||||
|
import javafx.stage.Screen;
|
||||||
|
import javafx.stage.Stage;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One top-level terminal window: its own {@link Stage}, {@link Compositor}, config/metrics, render
|
||||||
|
* loop and input handling. Many of these live in a single JVM under a {@link WindowManager}; closing
|
||||||
|
* one tears down only that window (its shells, its Stage) and leaves the rest — and, in daemon mode,
|
||||||
|
* the JVM — running. Built on the FX thread.
|
||||||
|
*/
|
||||||
|
final class TerminalWindow {
|
||||||
|
private final WindowManager manager;
|
||||||
|
private final TerminalMetrics metrics;
|
||||||
|
private final Compositor compositor;
|
||||||
|
private final Stage stage;
|
||||||
|
private final AnimationTimer renderLoop;
|
||||||
|
private AppConfig config;
|
||||||
|
private boolean closed;
|
||||||
|
|
||||||
|
TerminalWindow(WindowManager manager, String workingDirectory) {
|
||||||
|
this.manager = manager;
|
||||||
|
// Each window loads config independently, so edits (and per-window font changes) apply to
|
||||||
|
// newly opened windows without disturbing existing ones.
|
||||||
|
config = AppConfig.load();
|
||||||
|
StartupTiming.mark("config loaded");
|
||||||
|
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
|
||||||
|
StartupTiming.mark("fonts loaded");
|
||||||
|
compositor = new Compositor(config, metrics, workingDirectory);
|
||||||
|
StartupTiming.mark("compositor ready");
|
||||||
|
|
||||||
|
// The last pane closing closes this window (not the JVM); see teardown().
|
||||||
|
compositor.setOnEmpty(this::teardown);
|
||||||
|
|
||||||
|
StackPane root = new StackPane(compositor.canvas(), compositor.imageOverlay());
|
||||||
|
compositor.canvas().widthProperty().bind(root.widthProperty());
|
||||||
|
compositor.canvas().heightProperty().bind(root.heightProperty());
|
||||||
|
|
||||||
|
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
|
||||||
|
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
|
||||||
|
scene.addEventFilter(KeyEvent.KEY_TYPED, this::handleTyped);
|
||||||
|
|
||||||
|
renderLoop = new AnimationTimer() {
|
||||||
|
@Override
|
||||||
|
public void handle(long now) {
|
||||||
|
compositor.render();
|
||||||
|
StartupTiming.firstFrame();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
renderLoop.start();
|
||||||
|
|
||||||
|
stage = new Stage();
|
||||||
|
stage.setTitle("jprototerm");
|
||||||
|
stage.setScene(scene);
|
||||||
|
// The X11 WM close button: tear this window down explicitly. With implicit exit disabled
|
||||||
|
// (see WindowManager) nothing else would reap the shells or drop the window otherwise.
|
||||||
|
stage.setOnCloseRequest(event -> teardown());
|
||||||
|
centreOnActiveScreen(stage, config.windowWidth(), config.windowHeight());
|
||||||
|
stage.show();
|
||||||
|
StartupTiming.mark("stage shown");
|
||||||
|
// Ask the window manager to raise and focus the new window so the user can type right
|
||||||
|
// away; the canvas requestFocus() below only routes events within the scene.
|
||||||
|
stage.toFront();
|
||||||
|
stage.requestFocus();
|
||||||
|
compositor.canvas().requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully tears this window down (FX thread, idempotent): stops rendering, closes the compositor —
|
||||||
|
* which reaps the pane shells via the configured {@code close_signal} — disposes the Stage, and
|
||||||
|
* notifies the manager so it can drop the window (and, in standalone mode, exit the JVM). Both
|
||||||
|
* the WM close button and the last-pane-closed hook route through here.
|
||||||
|
*/
|
||||||
|
void teardown() {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closed = true;
|
||||||
|
renderLoop.stop();
|
||||||
|
compositor.close();
|
||||||
|
stage.close();
|
||||||
|
manager.onWindowClosed(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signals and reaps this window's shell processes without touching render state (off-FX safe). */
|
||||||
|
void terminateSessions() {
|
||||||
|
compositor.terminateSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handlePressed(KeyEvent event) {
|
||||||
|
if (config.keybindings().get("navigate_left").matches(event)) {
|
||||||
|
compositor.navigate(Direction.LEFT);
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("navigate_down").matches(event)) {
|
||||||
|
compositor.navigate(Direction.DOWN);
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("navigate_up").matches(event)) {
|
||||||
|
compositor.navigate(Direction.UP);
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("navigate_right").matches(event)) {
|
||||||
|
compositor.navigate(Direction.RIGHT);
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("toggle_floating").matches(event)) {
|
||||||
|
compositor.toggleFloating();
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("new_pane").matches(event)) {
|
||||||
|
compositor.createPane();
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("next_floating").matches(event)) {
|
||||||
|
compositor.nextFloatingPane();
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("promote_floating").matches(event)) {
|
||||||
|
compositor.promoteActiveFloating();
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("close_pane").matches(event)) {
|
||||||
|
// Closing the last pane closes this window, via the compositor's onEmpty hook.
|
||||||
|
compositor.closeActivePane();
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("new_tab").matches(event)) {
|
||||||
|
compositor.newTab();
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("previous_tab").matches(event)) {
|
||||||
|
compositor.previousTab();
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("next_tab").matches(event)) {
|
||||||
|
compositor.nextTab();
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("open_font_selector").matches(event)) {
|
||||||
|
openFontSelector();
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("open_scrollback").matches(event)) {
|
||||||
|
openScrollbackInEditor();
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("paste").matches(event)) {
|
||||||
|
pasteFromClipboard();
|
||||||
|
event.consume();
|
||||||
|
} else {
|
||||||
|
String encoded = KeyEncoder.encode(event);
|
||||||
|
if (encoded != null) {
|
||||||
|
compositor.activePane().send(encoded);
|
||||||
|
event.consume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleTyped(KeyEvent event) {
|
||||||
|
if (event.isAltDown() || event.isControlDown() || event.isMetaDown()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String text = event.getCharacter();
|
||||||
|
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
|
||||||
|
compositor.activePane().send(text);
|
||||||
|
event.consume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pasteFromClipboard() {
|
||||||
|
Clipboard clipboard = Clipboard.getSystemClipboard();
|
||||||
|
if (clipboard.hasString()) {
|
||||||
|
compositor.activePane().paste(clipboard.getString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openFontSelector() {
|
||||||
|
Dialog<ButtonType> dialog = new Dialog<>();
|
||||||
|
dialog.setTitle("Font");
|
||||||
|
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
|
||||||
|
|
||||||
|
ComboBox<String> family = new ComboBox<>();
|
||||||
|
family.getItems().setAll(Font.getFamilies());
|
||||||
|
family.setEditable(true);
|
||||||
|
family.setMaxWidth(Double.MAX_VALUE);
|
||||||
|
family.setValue(config.fontFamily());
|
||||||
|
|
||||||
|
Spinner<Double> size = new Spinner<>();
|
||||||
|
size.setEditable(true);
|
||||||
|
size.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(6.0, 48.0, config.fontSize(), 0.5));
|
||||||
|
|
||||||
|
GridPane content = new GridPane();
|
||||||
|
content.setHgap(10.0);
|
||||||
|
content.setVgap(10.0);
|
||||||
|
content.add(new Label("Family"), 0, 0);
|
||||||
|
content.add(family, 1, 0);
|
||||||
|
content.add(new Label("Size"), 0, 1);
|
||||||
|
content.add(size, 1, 1);
|
||||||
|
dialog.getDialogPane().setContent(content);
|
||||||
|
|
||||||
|
dialog.showAndWait()
|
||||||
|
.filter(button -> button == ButtonType.OK)
|
||||||
|
.ifPresent(ignored -> {
|
||||||
|
String selectedFamily = family.getEditor().getText();
|
||||||
|
if (selectedFamily == null || selectedFamily.isBlank()) {
|
||||||
|
selectedFamily = family.getValue();
|
||||||
|
}
|
||||||
|
if (selectedFamily == null || selectedFamily.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double selectedSize = size.getValue();
|
||||||
|
config = config.withFont(selectedFamily.trim(), selectedSize);
|
||||||
|
config.save();
|
||||||
|
compositor.setFont(config.fontFamily(), config.fontSize());
|
||||||
|
compositor.canvas().requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openScrollbackInEditor() {
|
||||||
|
try {
|
||||||
|
// Capture the active pane's scrollback before opening the floating pane, since that
|
||||||
|
// makes the new pane active.
|
||||||
|
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
||||||
|
Files.writeString(file, compositor.activePane().scrollbackText());
|
||||||
|
file.toFile().deleteOnExit();
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// — no shell startup/rc race — and the pane auto-closes when the editor exits.
|
||||||
|
compositor.openFloatingPane(scrollbackEditorCommand(file));
|
||||||
|
} catch (IOException ex) {
|
||||||
|
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String scrollbackEditorCommand(Path file) {
|
||||||
|
String quotedFile = shellQuote(file.toString());
|
||||||
|
String command = config.scrollbackEditorCommand();
|
||||||
|
if (command == null || command.isBlank()) {
|
||||||
|
command = "vi {file}";
|
||||||
|
}
|
||||||
|
if (command.contains("{file}")) {
|
||||||
|
return command.replace("{file}", quotedFile);
|
||||||
|
}
|
||||||
|
return command + " " + quotedFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String shellQuote(String value) {
|
||||||
|
return "'" + value.replace("'", "'\"'\"'") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Centre the stage within the screen the mouse pointer is on (the best proxy for the
|
||||||
|
// "active" monitor on X11, which exposes no focused-monitor concept to JavaFX).
|
||||||
|
private static void centreOnActiveScreen(Stage stage, double width, double height) {
|
||||||
|
Rectangle2D bounds = activeScreen().getVisualBounds();
|
||||||
|
stage.setX(bounds.getMinX() + ((bounds.getWidth() - width) / 2.0));
|
||||||
|
stage.setY(bounds.getMinY() + ((bounds.getHeight() - height) / 2.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Screen activeScreen() {
|
||||||
|
int[] at = X11Pointer.query();
|
||||||
|
if (at != null) {
|
||||||
|
// libX11 and JavaFX share a coordinate space on the X11 virtual screen.
|
||||||
|
List<Screen> screens = Screen.getScreensForRectangle(at[0], at[1], 1.0, 1.0);
|
||||||
|
if (!screens.isEmpty()) {
|
||||||
|
return screens.get(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Screen.getPrimary();
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/main/java/com/gregor/jprototerm/WindowManager.java
Normal file
69
src/main/java/com/gregor/jprototerm/WindowManager.java
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import javafx.application.Platform;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.CopyOnWriteArraySet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the JavaFX toolkit lifecycle and the set of live {@link TerminalWindow}s for one JVM. A
|
||||||
|
* single JVM hosts every window, so the expensive toolkit/GL init is paid once; opening another
|
||||||
|
* window is then just a new {@link javafx.stage.Stage}.
|
||||||
|
*
|
||||||
|
* <p>Two modes differ only in the empty policy: in {@link Mode#STANDALONE} (today's behavior, used
|
||||||
|
* as the fallback when no daemon is reachable) the JVM exits once the last window closes; in
|
||||||
|
* {@link Mode#DAEMON} the toolkit stays alive with zero windows, waiting for the next client
|
||||||
|
* request. {@link Platform#setImplicitExit(boolean) implicit exit} is disabled in both so the
|
||||||
|
* toolkit never tears itself down behind our back — every exit is an explicit decision here.
|
||||||
|
*/
|
||||||
|
public final class WindowManager {
|
||||||
|
public enum Mode {
|
||||||
|
STANDALONE,
|
||||||
|
DAEMON
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Mode mode;
|
||||||
|
// Mutated on the FX thread (register/deregister), iterated from the shutdown-hook thread.
|
||||||
|
private final Set<TerminalWindow> windows = new CopyOnWriteArraySet<>();
|
||||||
|
|
||||||
|
private WindowManager(Mode mode) {
|
||||||
|
this.mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings up the JavaFX toolkit (once per JVM) and returns a manager in {@code mode}. Registers a
|
||||||
|
* shutdown hook that reaps every window's shell processes, so child shells are terminated rather
|
||||||
|
* than orphaned if the JVM is killed (SIGTERM/SIGINT/SIGHUP) — see
|
||||||
|
* {@link Compositor#terminateSessions()}.
|
||||||
|
*/
|
||||||
|
public static WindowManager start(Mode mode) {
|
||||||
|
WindowManager manager = new WindowManager(mode);
|
||||||
|
Platform.setImplicitExit(false);
|
||||||
|
Platform.startup(() -> StartupTiming.mark("toolkit ready"));
|
||||||
|
Runtime.getRuntime().addShutdownHook(new Thread(manager::terminateAllSessions, "shell-cleanup"));
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens a new window (on the FX thread) whose first pane starts in {@code workingDirectory}. */
|
||||||
|
public void openWindow(String workingDirectory) {
|
||||||
|
Platform.runLater(() -> windows.add(new TerminalWindow(this, workingDirectory)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by a window when it has finished tearing down (FX thread). Drops it from the registry
|
||||||
|
* and, in standalone mode, exits the JVM once none remain.
|
||||||
|
*/
|
||||||
|
void onWindowClosed(TerminalWindow window) {
|
||||||
|
windows.remove(window);
|
||||||
|
if (mode == Mode.STANDALONE && windows.isEmpty()) {
|
||||||
|
Platform.exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signals and reaps every live window's shell processes. Safe to call off the FX thread. */
|
||||||
|
void terminateAllSessions() {
|
||||||
|
for (TerminalWindow window : windows) {
|
||||||
|
window.terminateSessions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user