diff --git a/WRITING.md b/WRITING.md index 7c644d35..ddc1a3d1 100644 --- a/WRITING.md +++ b/WRITING.md @@ -121,6 +121,9 @@ routine site tests. A changed hash needs a new native run before review. Use `--example attach` to check the existing-server programs in the attach guide. Use `--example query --port ts` to run every complete program on the TypeScript filtering page, including its displayed setup and error assertions. +Use `--example concept --port fsharp --page concepts/queries` for the native +concept pages. Each run command checks its own expected output; a later success +cannot hide an earlier missing result. Put explanatory comments on separate lines above the code they describe. Limit example comments to 100 columns, including indentation; prefer shorter diff --git a/packages/api-model/src/builtins.ts b/packages/api-model/src/builtins.ts index b0e51ad4..6ddc6bfb 100644 --- a/packages/api-model/src/builtins.ts +++ b/packages/api-model/src/builtins.ts @@ -42,6 +42,7 @@ export const BUILTINS: Record> = { Flow: 'https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/', StateFlow: 'https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/', 'collect()': 'https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/collect.html', + 'singleOrNull()': 'https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.collections/single-or-null.html', }, scala: { String: scala('scala/Predef$'), Boolean: scala('scala/Boolean'), diff --git a/scripts/check-example-prose.py b/scripts/check-example-prose.py index 9fc7c8e0..5f7257cc 100644 --- a/scripts/check-example-prose.py +++ b/scripts/check-example-prose.py @@ -19,15 +19,19 @@ def main(): repo = Path(__file__).resolve().parent.parent parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--example', choices=['capture', 'attach', 'query'], default='capture') + parser.add_argument('--example', choices=['capture', 'attach', 'query', 'concept'], default='capture') parser.add_argument('--port', required=True) + parser.add_argument('--page', help='Page path within the port, for example concepts/queries') parser.add_argument('--output-dir', required=True, type=Path) args = parser.parse_args() manifest = json.loads((repo / f'site/test/fixtures/{args.example}-examples.json').read_text()) - examples = {item['port']: item for item in manifest['examples']} - if args.port not in examples: - parser.error(f'Unknown port: {args.port}; choose from {", ".join(examples)}') - example = examples[args.port] + examples = [item for item in manifest['examples'] if item['port'] == args.port] + if args.page: + examples = [item for item in examples if item['page'] == f'ports/{args.port}/{args.page}'] + if len(examples) != 1: + choices = ', '.join(item['page'] for item in examples) + parser.error(f'Choose one example with --port and --page; matching pages: {choices or "none"}') + example = examples[0] page = repo / 'site/src/content/docs' / (example['page'] + '.md') content = page.read_text() blocks = list(re.finditer(r'^```(\S+)([^\n]*)\n(.*?)^```', content, re.M | re.S)) @@ -48,6 +52,10 @@ def main(): for block in blocks if block[1] == 'console'] if not commands or commands != example['shellRecipe']: raise ValueError('Setup commands differ from the verification record') + expected = example.get('expectedOutputs', [[] for _ in commands[:-1]] + + [[example.get('expectedOutput', 'libtmux capture ready')]]) + if len(expected) != len(commands): + raise ValueError('Expected output must be recorded for each setup or run command') output = args.output_dir.resolve() output.mkdir(parents=True, exist_ok=False) @@ -66,13 +74,14 @@ def main(): with log.open('w') as stream: result = subprocess.run(['sh', '-eu', '-c', command], cwd=output, env=env, stdout=stream, stderr=subprocess.STDOUT) + missing = [line for line in expected[index] if line not in log.read_text().splitlines()] results.append({'command': command, 'exit': result.returncode, - 'seconds': round(time.monotonic() - start, 3)}) - if result.returncode: + 'seconds': round(time.monotonic() - start, 3), 'missingOutput': missing}) + if result.returncode or missing: break - passed = all(row['exit'] == 0 for row in results) - passed = passed and example.get('expectedOutput', 'libtmux capture ready') in log.read_text().splitlines() - report = {'port': args.port, 'sourceRevision': example['sourceRevision'], + passed = len(results) == len(commands) and all( + row['exit'] == 0 and not row['missingOutput'] for row in results) + report = {'port': args.port, 'page': example['page'], 'sourceRevision': example['sourceRevision'], 'pageSha256': hashlib.sha256(page.read_bytes()).hexdigest(), 'files': example['files'], 'runs': results, 'passed': passed, 'scope': 'Exact displayed program and setup; native execution on this host.'} diff --git a/site/src/content/docs/concepts/index.md b/site/src/content/docs/concepts/index.md index 7d8ff0fc..fba85124 100644 --- a/site/src/content/docs/concepts/index.md +++ b/site/src/content/docs/concepts/index.md @@ -1,4 +1,5 @@ --- +supportedPorts: [py, ts, rs, go, java, dotnet, cxx, swift, ruby, lua] title: Concepts description: tmux objects, command transports, queries, and workspaces. sidebar: diff --git a/site/src/content/docs/concepts/server-session-window-pane.md b/site/src/content/docs/concepts/server-session-window-pane.md index f3d912d0..ef6e0530 100644 --- a/site/src/content/docs/concepts/server-session-window-pane.md +++ b/site/src/content/docs/concepts/server-session-window-pane.md @@ -1,4 +1,5 @@ --- +supportedPorts: [py, ts, rs, go, java, dotnet, cxx, swift, ruby, lua] title: Server, session, window, pane description: The object hierarchy every libtmux port mirrors from tmux itself, and the client that sits outside it. sidebar: diff --git a/site/src/content/docs/ports/fsharp/concepts/index.md b/site/src/content/docs/ports/fsharp/concepts/index.md new file mode 100644 index 00000000..a297cf42 --- /dev/null +++ b/site/src/content/docs/ports/fsharp/concepts/index.md @@ -0,0 +1,22 @@ +--- +port: fsharp +route: concepts +title: F# concepts +description: Understand F# handles, filters, transports and layout ownership. +sidebar: + label: F# concepts + group: Concepts + order: 1 +tableOfContents: true +--- + +Use these concepts to reason about F# handles, selection and command execution. Each page includes complete programs with imports, project files, run commands and cleanup. + +| Concept | What you will do | +| --- | --- | +| [Server, session, window, pane](./server-session-window-pane/) | Traverse a capture and refresh after a rename. | +| [Filtering and queries](./queries/) | Combine predicates, handle result counts and match related windows. | +| [Commands and control mode](./transports/) | Run bounded commands and manage a persistent control client. | +| [Layouts and repeated setup](./workspaces/) | Create a split window and reuse a named window safely. | + +For individual types and operations, open the [API reference](../reference/). For a first connection, start with [attaching to tmux](../guides/attaching-to-tmux/). diff --git a/site/src/content/docs/ports/fsharp/concepts/queries.md b/site/src/content/docs/ports/fsharp/concepts/queries.md new file mode 100644 index 00000000..1fdbbed2 --- /dev/null +++ b/site/src/content/docs/ports/fsharp/concepts/queries.md @@ -0,0 +1,251 @@ +--- +port: fsharp +route: concepts/queries +title: Filtering and queries +description: Compose F# filters, distinguish zero and several matches, and query captured relations. +sidebar: + label: Filtering and queries + group: Concepts + order: 4 +tableOfContents: true +--- + +Filter captured objects in F#, handle result counts explicitly, and match sessions by their related windows. Local filters read captured values; they do not subscribe to changes or issue a fresh tmux query. + +[`Filter`](../../reference/libtmux-fsharp-filter/) supplies equality, ordinal prefix matching and membership. Use `allOf`, `anyOf` and `negate` to compose conditions. [`Query.matching`](../../reference/libtmux-fsharp-query-matching/) materializes the matching objects in source order. Use `Seq.filter` for an application predicate that does not need a portable query. + +## Setup and run + +Use an empty directory on Linux with Git, tmux 3.2a or newer, and .NET SDK 10.0.302. Save the project files and launcher below, then save any complete program on this page. Each program has its own imports and entry point. + +```xml title="Query.fsproj" + + + Exe + net10.0 + Local + + + + + + +``` + +The launcher creates two sessions on a private socket: `work-one` with an `editor` window, and `work-two` with a `logs` window. Each pane runs `cat` so it stays alive. It stops only this server when the program finishes or fails. If shutdown fails, it reports the retained directory and exits with an error. + +```sh title="run.sh" +#!/bin/sh +set -eu + +binary=$(command -v tmux) +mkdir -p /tmp/libtmux-dotnet-dev +directory=$(mktemp -d /tmp/libtmux-dotnet-dev/query.XXXXXX) +socket="$directory/tmux.sock" + +cleanup() { + status=$? + trap - 0 HUP INT TERM + if [ -S "$socket" ] && ! "$binary" -S "$socket" kill-server; then + printf 'Cannot stop tmux; kept %s\n' "$directory" >&2 + exit 1 + fi + rm -rf "$directory" || exit 1 + exit "$status" +} +trap cleanup 0 +trap 'exit 1' HUP INT TERM + +unset TMUX TMUX_PANE +export LIBTMUX_SOCKET_PATH="$socket" TMUX_BIN="$binary" +"$binary" -S "$socket" -f /dev/null new-session -d -s work-one -n editor /bin/cat +"$binary" -S "$socket" new-session -d -s work-two -n logs /bin/cat +"$@" +"$binary" -S "$socket" has-session -t '=work-one' +``` + +Fetch the library revision used by these examples: + +```console +$ git clone https://github.com/libtmux/libtmux-dotnet libtmux-source && + git -C libtmux-source checkout 2d99ead5aba8d968e85dcac8c9a9a518452e6ba6 +``` + +Each run starts from the same two-session fixture. Programs do not depend on another example having run first. Their assertions fail if the observed result differs. + +## Match names and combine conditions + +Select the two names beginning with `work-`, then assert equality, AND, OR and exclusion against the same captured collection. Matching is case sensitive. The native collection predicate agrees with the typed prefix query. + +```fsharp title="Local.fs" +open System +open System.Threading +open System.Threading.Tasks +open LibTmux +open LibTmux.FSharp + +let run () = task { + let socket = Environment.GetEnvironmentVariable("LIBTMUX_SOCKET_PATH") + if String.IsNullOrEmpty(socket) then + invalidOp "Set LIBTMUX_SOCKET_PATH to an existing socket" + use timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5.0)) + let token = timeout.Token + let! server = LibTmux.Server.ConnectAsync( + ServerConnectionOptions(SocketPath = socket), token) + let! captured = server |> Server.capture token SnapshotDepth.Sessions + let prefix = Filter.startsWith "work-" SessionFields.name + let matching = captured.Sessions |> Query.matching prefix + let names = matching |> Seq.map (fun session -> session.Name) |> Seq.sort |> Seq.toList + if names <> [ "work-one"; "work-two" ] then failwith "Unexpected sessions" + printfn "%s" (String.concat ", " names) + let native = captured.Sessions |> Seq.filter (fun session -> session.Name.StartsWith("work-")) + if Seq.length native <> matching.Count then failwith "Local filters disagree" + let onlyOne = Filter.allOf [ + Filter.startsWith "work-" SessionFields.name + Filter.eq "work-one" SessionFields.name + ] + let selected = captured.Sessions |> Query.matching onlyOne + if selected.Count <> 1 || selected[0].Name <> "work-one" then failwith "Wrong AND result" + let either = Filter.oneOf [ "work-one"; "work-two" ] SessionFields.name + if (captured.Sessions |> Query.matching either).Count <> 2 then failwith "Wrong OR result" + let excluded = Filter.eq "work-one" SessionFields.name |> Filter.negate + let remaining = captured.Sessions |> Query.matching excluded + if remaining.Count <> 1 || remaining[0].Name <> "work-two" then failwith "Wrong NOT result" +} + +[] +let main _ = + try + run().GetAwaiter().GetResult() + 0 + with error -> + eprintfn "%O" error + 1 +``` + +```console +$ dotnet build Query.fsproj --maxcpucount:1 -p:Example=Local \ + -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath="$PWD/.packages" && + sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Local +``` + +Expected program output: + +```text +work-one, work-two +``` + +## Distinguish missing and ambiguous results + +[`Selection.exactlyOne`](../../reference/libtmux-fsharp-selection-exactlyone/) returns a `Result`. Match `NoMatches` and `MultipleMatches` separately. A missing optional target and an ambiguous destructive target should not take the same path. + +```fsharp title="Cardinality.fs" +open System +open System.Threading +open System.Threading.Tasks +open LibTmux +open LibTmux.FSharp + +let run () = task { + let socket = Environment.GetEnvironmentVariable("LIBTMUX_SOCKET_PATH") + if String.IsNullOrEmpty(socket) then + invalidOp "Set LIBTMUX_SOCKET_PATH to an existing socket" + use timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5.0)) + let token = timeout.Token + let! server = LibTmux.Server.ConnectAsync( + ServerConnectionOptions(SocketPath = socket), token) + let! captured = server |> Server.capture token SnapshotDepth.Sessions + for name in [ "work-one"; "missing" ] do + let matches = captured.Sessions |> Query.matching (Filter.eq name SessionFields.name) + match matches |> Selection.exactlyOne with + | Ok session -> printfn "%s: selected" session.Name + | Error CardinalityError.NoMatches -> printfn "%s: absent" name + | Error CardinalityError.MultipleMatches -> failwithf "Ambiguous session: %s" name + let many = captured.Sessions |> Selection.exactlyOne + match many with + | Error CardinalityError.MultipleMatches -> printfn "work-: ambiguous" + | _ -> failwith "Expected two sessions" +} + +[] +let main _ = + try + run().GetAwaiter().GetResult() + 0 + with error -> + eprintfn "%O" error + 1 +``` + +```console +$ dotnet build Query.fsproj --maxcpucount:1 -p:Example=Cardinality \ + -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath="$PWD/.packages" && + sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Cardinality +``` + +Expected program output: + +```text +work-one: selected +missing: absent +work-: ambiguous +``` + +## Filter through related windows + +Match sessions with any window named `editor`, then match sessions with none. For an empty captured relation, `any` is false and `none` is true; `all` is true. An uncaptured relation is a different condition and must be captured before filtering. This program derives the required snapshot depth from the query document. + +```fsharp title="Relations.fs" +open System +open System.Threading +open System.Threading.Tasks +open LibTmux +open LibTmux.FSharp + +let run () = task { + let socket = Environment.GetEnvironmentVariable("LIBTMUX_SOCKET_PATH") + if String.IsNullOrEmpty(socket) then + invalidOp "Set LIBTMUX_SOCKET_PATH to an existing socket" + use timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5.0)) + let token = timeout.Token + let! server = LibTmux.Server.ConnectAsync( + ServerConnectionOptions(SocketPath = socket), token) + let editor = Filter.eq "editor" WindowFields.name + let withEditor = editor |> Filter.any SessionFields.windows + let withoutEditor = editor |> Filter.none SessionFields.windows + let depth = (Filter.toDocument withEditor).RequiredSnapshotDepth + let! captured = server |> Server.capture token depth + let selected = captured.Sessions |> Query.matching withEditor + let excluded = captured.Sessions |> Query.matching withoutEditor + if selected.Count <> 1 || selected[0].Name <> "work-one" then failwith "Wrong editor session" + if excluded.Count <> 1 || excluded[0].Name <> "work-two" then failwith "Wrong other session" + printfn "editor: %s" selected[0].Name + printfn "no editor: %s" excluded[0].Name +} + +[] +let main _ = + try + run().GetAwaiter().GetResult() + 0 + with error -> + eprintfn "%O" error + 1 +``` + +```console +$ dotnet build Query.fsproj --maxcpucount:1 -p:Example=Relations \ + -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath="$PWD/.packages" && + sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Relations +``` + +Expected program output: + +```text +editor: work-one +no editor: work-two +``` + +## Refresh before repeating a decision + +Reusing the same list repeats the same decision over old state. Capture again when you need to observe a rename, new window or closed pane. The [snapshot example](../server-session-window-pane/#observe-a-rename-with-a-fresh-read) shows the old and fresh values side by side. diff --git a/site/src/content/docs/ports/fsharp/concepts/server-session-window-pane.md b/site/src/content/docs/ports/fsharp/concepts/server-session-window-pane.md new file mode 100644 index 00000000..8fd684ab --- /dev/null +++ b/site/src/content/docs/ports/fsharp/concepts/server-session-window-pane.md @@ -0,0 +1,187 @@ +--- +port: fsharp +route: concepts/server-session-window-pane +title: Server, session, window, pane +description: Traverse captured F# handles, retain IDs and refresh after changes. +sidebar: + label: Server, session, window, pane + group: Concepts + order: 2 +tableOfContents: true +--- + +Capture a hierarchy, then read its sessions, windows and panes locally. Handles retain the state they captured; a rename does not rewrite an older handle. Use a new capture to observe later changes. + +[`Server`](../../reference/libtmux-fsharp-server/) is the F# helper module. It operates on the underlying [`LibTmux.Server`](../../../../dotnet/latest/reference/libtmux-server/) object. Choose a snapshot depth before traversal: sessions, windows or panes. A relation that was not captured is unavailable, rather than an empty collection. + +A session holds window placements; a window holds panes. A linked window can appear in several sessions, so traversal counts placements rather than necessarily distinct physical windows. Names can change; retain IDs when identifying a target. + +## Setup and run + +Use an empty directory on Linux with Git, tmux 3.2a or newer, and .NET SDK 10.0.302. Save the project files and launcher below, then save any complete program on this page. Each program has its own imports and entry point. + +```xml title="Query.fsproj" + + + Exe + net10.0 + Local + + + + + + +``` + +The launcher creates two sessions on a private socket: `work-one` with an `editor` window, and `work-two` with a `logs` window. Each pane runs `cat` so it stays alive. It stops only this server when the program finishes or fails. If shutdown fails, it reports the retained directory and exits with an error. + +```sh title="run.sh" +#!/bin/sh +set -eu + +binary=$(command -v tmux) +mkdir -p /tmp/libtmux-dotnet-dev +directory=$(mktemp -d /tmp/libtmux-dotnet-dev/query.XXXXXX) +socket="$directory/tmux.sock" + +cleanup() { + status=$? + trap - 0 HUP INT TERM + if [ -S "$socket" ] && ! "$binary" -S "$socket" kill-server; then + printf 'Cannot stop tmux; kept %s\n' "$directory" >&2 + exit 1 + fi + rm -rf "$directory" || exit 1 + exit "$status" +} +trap cleanup 0 +trap 'exit 1' HUP INT TERM + +unset TMUX TMUX_PANE +export LIBTMUX_SOCKET_PATH="$socket" TMUX_BIN="$binary" +"$binary" -S "$socket" -f /dev/null new-session -d -s work-one -n editor /bin/cat +"$binary" -S "$socket" new-session -d -s work-two -n logs /bin/cat +"$@" +"$binary" -S "$socket" has-session -t '=work-one' +``` + +Fetch the library revision used by these examples: + +```console +$ git clone https://github.com/libtmux/libtmux-dotnet libtmux-source && + git -C libtmux-source checkout 2d99ead5aba8d968e85dcac8c9a9a518452e6ba6 +``` + +Each run starts from the same two-session fixture. Programs do not depend on another example having run first. Their assertions fail if the observed result differs. + +## Walk sessions, windows and panes + +Flatten the captured children and verify the fixture contains two of each. The program also prints each session and its window. + +```fsharp title="Hierarchy.fs" +open System +open System.Threading +open System.Threading.Tasks +open LibTmux +open LibTmux.FSharp + +let run () = task { + let socket = Environment.GetEnvironmentVariable("LIBTMUX_SOCKET_PATH") + if String.IsNullOrEmpty(socket) then + invalidOp "Set LIBTMUX_SOCKET_PATH to an existing socket" + use timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5.0)) + let token = timeout.Token + let! server = LibTmux.Server.ConnectAsync( + ServerConnectionOptions(SocketPath = socket), token) + let! captured = server |> Server.capture token SnapshotDepth.Panes + let sessions = captured.Sessions + let windows = sessions |> Seq.collect (fun session -> session.Windows) |> Seq.toList + let panes = windows |> Seq.collect (fun window -> window.Panes) |> Seq.toList + if sessions.Count <> 2 || windows.Length <> 2 || panes.Length <> 2 then + failwith "Unexpected hierarchy" + for session in sessions do + printfn "%s: %s" session.Name session.Windows[0].Name + printfn "2 sessions, 2 windows, 2 panes" +} + +[] +let main _ = + try + run().GetAwaiter().GetResult() + 0 + with error -> + eprintfn "%O" error + 1 +``` + +```console +$ dotnet build Query.fsproj --maxcpucount:1 -p:Example=Hierarchy \ + -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath="$PWD/.packages" && + sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Hierarchy +``` + +Expected program output: + +```text +work-one: editor +work-two: logs +2 sessions, 2 windows, 2 panes +``` + +## Observe a rename with a fresh read + +Rename the editor window. The old handle still says `editor`; the returned handle and a new server read say `renamed`. This distinction matters when a UI, another client or your own code changes tmux after a capture. + +```fsharp title="Refresh.fs" +open System +open System.Threading +open System.Threading.Tasks +open LibTmux +open LibTmux.FSharp + +let run () = task { + let socket = Environment.GetEnvironmentVariable("LIBTMUX_SOCKET_PATH") + if String.IsNullOrEmpty(socket) then + invalidOp "Set LIBTMUX_SOCKET_PATH to an existing socket" + use timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5.0)) + let token = timeout.Token + let! server = LibTmux.Server.ConnectAsync( + ServerConnectionOptions(SocketPath = socket), token) + let! captured = server |> Server.capture token SnapshotDepth.Windows + let session = captured.Sessions |> Seq.find (fun session -> session.Name = "work-one") + let before = session.Windows[0] + let! after = before.RenameAsync("renamed", token) + if before.Name <> "editor" || after.Name <> "renamed" then failwith "Unexpected rename state" + let! refreshed = server |> Server.capture token SnapshotDepth.Windows + let session = refreshed.Sessions |> Seq.find (fun s -> s.Name = "work-one") + let window = session.Windows[0] + if window.Name <> "renamed" then failwith "Fresh read did not see the rename" + printfn "%s -> %s" before.Name window.Name +} + +[] +let main _ = + try + run().GetAwaiter().GetResult() + 0 + with error -> + eprintfn "%O" error + 1 +``` + +```console +$ dotnet build Query.fsproj --maxcpucount:1 -p:Example=Refresh \ + -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath="$PWD/.packages" && + sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Refresh +``` + +Expected program output: + +```text +editor -> renamed +``` + +## Select a target before changing it + +A successful lookup does not reserve a tmux object. Another client can remove it before your next command. Handle a command failure at the mutation, and refresh before deciding what to do next. See [filtering and queries](../queries/) for missing and ambiguous selections, and [layouts](../workspaces/) for creation. diff --git a/site/src/content/docs/ports/fsharp/concepts/transports.md b/site/src/content/docs/ports/fsharp/concepts/transports.md new file mode 100644 index 00000000..b0fc28a1 --- /dev/null +++ b/site/src/content/docs/ports/fsharp/concepts/transports.md @@ -0,0 +1,196 @@ +--- +port: fsharp +route: concepts/transports +title: Commands and control mode +description: Use F# command calls and persistent control connections with explicit cleanup. +sidebar: + label: Commands and control mode + group: Concepts + order: 5 +tableOfContents: true +--- + +Choose command calls for bounded operations and a control connection when you need a persistent tmux client. Closing a client releases its resources; it does not mean the tmux server should be stopped. + +[`Server.capture`](../../reference/libtmux-fsharp-server-capture/) returns a task and takes an explicit cancellation token. The default connection executes command requests through subprocesses. [`Control.withSession`](../../reference/libtmux-fsharp-control-withsession/) brackets a persistent control session and closes it when the callback finishes. + +## Setup and run + +Use an empty directory on Linux with Git, tmux 3.2a or newer, and .NET SDK 10.0.302. Save the project files and launcher below, then save any complete program on this page. Each program has its own imports and entry point. + +```xml title="Query.fsproj" + + + Exe + net10.0 + Local + + + + + + +``` + +The launcher creates two sessions on a private socket: `work-one` with an `editor` window, and `work-two` with a `logs` window. Each pane runs `cat` so it stays alive. It stops only this server when the program finishes or fails. If shutdown fails, it reports the retained directory and exits with an error. + +```sh title="run.sh" +#!/bin/sh +set -eu + +binary=$(command -v tmux) +mkdir -p /tmp/libtmux-dotnet-dev +directory=$(mktemp -d /tmp/libtmux-dotnet-dev/query.XXXXXX) +socket="$directory/tmux.sock" + +cleanup() { + status=$? + trap - 0 HUP INT TERM + if [ -S "$socket" ] && ! "$binary" -S "$socket" kill-server; then + printf 'Cannot stop tmux; kept %s\n' "$directory" >&2 + exit 1 + fi + rm -rf "$directory" || exit 1 + exit "$status" +} +trap cleanup 0 +trap 'exit 1' HUP INT TERM + +unset TMUX TMUX_PANE +export LIBTMUX_SOCKET_PATH="$socket" TMUX_BIN="$binary" +"$binary" -S "$socket" -f /dev/null new-session -d -s work-one -n editor /bin/cat +"$binary" -S "$socket" new-session -d -s work-two -n logs /bin/cat +"$@" +"$binary" -S "$socket" has-session -t '=work-one' +``` + +Fetch the library revision used by these examples: + +```console +$ git clone https://github.com/libtmux/libtmux-dotnet libtmux-source && + git -C libtmux-source checkout 2d99ead5aba8d968e85dcac8c9a9a518452e6ba6 +``` + +Each run starts from the same two-session fixture. Programs do not depend on another example having run first. Their assertions fail if the observed result differs. + +## Run a bounded command and inspect its result + +Read the session list and filter it locally. The connection uses a five-second timeout so an unavailable server fails visibly. + +```fsharp title="Local.fs" +open System +open System.Threading +open System.Threading.Tasks +open LibTmux +open LibTmux.FSharp + +let run () = task { + let socket = Environment.GetEnvironmentVariable("LIBTMUX_SOCKET_PATH") + if String.IsNullOrEmpty(socket) then + invalidOp "Set LIBTMUX_SOCKET_PATH to an existing socket" + use timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5.0)) + let token = timeout.Token + let! server = LibTmux.Server.ConnectAsync( + ServerConnectionOptions(SocketPath = socket), token) + let! captured = server |> Server.capture token SnapshotDepth.Sessions + let prefix = Filter.startsWith "work-" SessionFields.name + let matching = captured.Sessions |> Query.matching prefix + let names = matching |> Seq.map (fun session -> session.Name) |> Seq.sort |> Seq.toList + if names <> [ "work-one"; "work-two" ] then failwith "Unexpected sessions" + printfn "%s" (String.concat ", " names) + let native = captured.Sessions |> Seq.filter (fun session -> session.Name.StartsWith("work-")) + if Seq.length native <> matching.Count then failwith "Local filters disagree" + let onlyOne = Filter.allOf [ + Filter.startsWith "work-" SessionFields.name + Filter.eq "work-one" SessionFields.name + ] + let selected = captured.Sessions |> Query.matching onlyOne + if selected.Count <> 1 || selected[0].Name <> "work-one" then failwith "Wrong AND result" + let either = Filter.oneOf [ "work-one"; "work-two" ] SessionFields.name + if (captured.Sessions |> Query.matching either).Count <> 2 then failwith "Wrong OR result" + let excluded = Filter.eq "work-one" SessionFields.name |> Filter.negate + let remaining = captured.Sessions |> Query.matching excluded + if remaining.Count <> 1 || remaining[0].Name <> "work-two" then failwith "Wrong NOT result" +} + +[] +let main _ = + try + run().GetAwaiter().GetResult() + 0 + with error -> + eprintfn "%O" error + 1 +``` + +```console +$ dotnet build Query.fsproj --maxcpucount:1 -p:Example=Local \ + -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath="$PWD/.packages" && + sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Local +``` + +Expected program output: + +```text +work-one, work-two +``` + +## Open and close a control client + +Send `list-sessions` over a persistent control connection, verify the response, then close the control client. A subsequent ordinary read verifies the tmux server still has both sessions. + +```fsharp title="Control.fs" +open System +open System.Threading +open System.Threading.Tasks +open LibTmux +open LibTmux.FSharp + +let run () = task { + let socket = Environment.GetEnvironmentVariable("LIBTMUX_SOCKET_PATH") + if String.IsNullOrEmpty(socket) then + invalidOp "Set LIBTMUX_SOCKET_PATH to an existing socket" + use timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5.0)) + let token = timeout.Token + let! server = LibTmux.Server.ConnectAsync( + ServerConnectionOptions(SocketPath = socket), token) + do! server |> Control.withSession token (fun control -> task { + let command = TmuxCommand.Create("list-sessions", "-F", "#{session_name}") + let! lines = control.SendAsync(command, token) + let names = lines |> Seq.sort |> Seq.toList + if names <> [ "work-one"; "work-two" ] then failwith "Unexpected sessions" + printfn "%s" (String.concat ", " names) + }) + let! captured = server |> Server.capture token SnapshotDepth.Sessions + if captured.Sessions.Count <> 2 then failwith "Server lost its sessions" + printfn "control client closed; server still running" +} + +[] +let main _ = + try + run().GetAwaiter().GetResult() + 0 + with error -> + eprintfn "%O" error + 1 +``` + +```console +$ dotnet build Query.fsproj --maxcpucount:1 -p:Example=Control \ + -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath="$PWD/.packages" && + sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Control +``` + +Expected program output: + +```text +work-one, work-two +control client closed; server still running +``` + +## Timeouts and ownership + +An operation that times out may already have reached tmux. Read the current state before retrying a mutation such as creating a window. A cancellation signal does not roll back a completed tmux command. + +Here the launcher owns the private server and stops it after the program exits. An application connecting to an existing server should close its own client resources and leave that server running. See [attaching to tmux](../../guides/attaching-to-tmux/) for the connection-only example. diff --git a/site/src/content/docs/ports/fsharp/concepts/workspaces.md b/site/src/content/docs/ports/fsharp/concepts/workspaces.md new file mode 100644 index 00000000..6013d8a3 --- /dev/null +++ b/site/src/content/docs/ports/fsharp/concepts/workspaces.md @@ -0,0 +1,193 @@ +--- +port: fsharp +route: concepts/workspaces +title: Layouts and repeated setup +description: Create and reuse F# tmux layouts while preserving running processes. +sidebar: + label: Layouts and repeated setup + group: Concepts + order: 6 +tableOfContents: true +--- + +Build a tmux layout from F# by creating a window, splitting a pane and selecting a layout. Capture again to inspect the result. The examples use the library APIs directly and give every pane a command that stays alive. + +## Setup and run + +Use an empty directory on Linux with Git, tmux 3.2a or newer, and .NET SDK 10.0.302. Save the project files and launcher below, then save any complete program on this page. Each program has its own imports and entry point. + +```xml title="Query.fsproj" + + + Exe + net10.0 + Local + + + + + + +``` + +The launcher creates two sessions on a private socket: `work-one` with an `editor` window, and `work-two` with a `logs` window. Each pane runs `cat` so it stays alive. It stops only this server when the program finishes or fails. If shutdown fails, it reports the retained directory and exits with an error. + +```sh title="run.sh" +#!/bin/sh +set -eu + +binary=$(command -v tmux) +mkdir -p /tmp/libtmux-dotnet-dev +directory=$(mktemp -d /tmp/libtmux-dotnet-dev/query.XXXXXX) +socket="$directory/tmux.sock" + +cleanup() { + status=$? + trap - 0 HUP INT TERM + if [ -S "$socket" ] && ! "$binary" -S "$socket" kill-server; then + printf 'Cannot stop tmux; kept %s\n' "$directory" >&2 + exit 1 + fi + rm -rf "$directory" || exit 1 + exit "$status" +} +trap cleanup 0 +trap 'exit 1' HUP INT TERM + +unset TMUX TMUX_PANE +export LIBTMUX_SOCKET_PATH="$socket" TMUX_BIN="$binary" +"$binary" -S "$socket" -f /dev/null new-session -d -s work-one -n editor /bin/cat +"$binary" -S "$socket" new-session -d -s work-two -n logs /bin/cat +"$@" +"$binary" -S "$socket" has-session -t '=work-one' +``` + +Fetch the library revision used by these examples: + +```console +$ git clone https://github.com/libtmux/libtmux-dotnet libtmux-source && + git -C libtmux-source checkout 2d99ead5aba8d968e85dcac8c9a9a518452e6ba6 +``` + +Each run starts from the same two-session fixture. Programs do not depend on another example having run first. Their assertions fail if the observed result differs. + +## Create a two-pane tools window + +Create `tools` in `work-one`, split its pane to the right, then choose `even-horizontal`. The final read verifies two panes. The previously captured session does not automatically gain the new window. + +```fsharp title="Layout.fs" +open System +open System.Threading +open System.Threading.Tasks +open LibTmux +open LibTmux.FSharp + +let run () = task { + let socket = Environment.GetEnvironmentVariable("LIBTMUX_SOCKET_PATH") + if String.IsNullOrEmpty(socket) then + invalidOp "Set LIBTMUX_SOCKET_PATH to an existing socket" + use timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5.0)) + let token = timeout.Token + let! server = LibTmux.Server.ConnectAsync( + ServerConnectionOptions(SocketPath = socket), token) + let! captured = server |> Server.capture token SnapshotDepth.Sessions + let session = captured.Sessions |> Seq.find (fun s -> s.Name = "work-one") + let request = NewWindowRequest(Name = "tools", Command = "/bin/cat") + let! window = session.CreateWindowAsync(request, token) + let! panes = window.GetPanesAsync(token) + let split = SplitPaneRequest(Direction = PaneDirection.Right, Command = "/bin/cat") + let! _ = panes[0] |> Pane.split token split + let! _ = window.SelectLayoutAsync(SelectLayoutRequest(Layout = "even-horizontal"), token) + let! refreshed = window.GetPanesAsync(token) + if refreshed.Count <> 2 then failwith "Expected two panes" + printfn "tools: 2 panes" +} + +[] +let main _ = + try + run().GetAwaiter().GetResult() + 0 + with error -> + eprintfn "%O" error + 1 +``` + +```console +$ dotnet build Query.fsproj --maxcpucount:1 -p:Example=Layout \ + -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath="$PWD/.packages" && + sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Layout +``` + +Expected program output: + +```text +tools: 2 panes +``` + +## Reuse a named window + +Look for `tools` before creating it. Two sequential calls return the same window ID, and the session has only `editor` and `tools`. This is useful for a setup command you run repeatedly. + +```fsharp title="ReuseLayout.fs" +open System +open System.Threading +open System.Threading.Tasks +open LibTmux +open LibTmux.FSharp + +let run () = task { + let socket = Environment.GetEnvironmentVariable("LIBTMUX_SOCKET_PATH") + if String.IsNullOrEmpty(socket) then + invalidOp "Set LIBTMUX_SOCKET_PATH to an existing socket" + use timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5.0)) + let token = timeout.Token + let! server = LibTmux.Server.ConnectAsync( + ServerConnectionOptions(SocketPath = socket), token) + let ensureTools () = task { + let! captured = server |> Server.capture token SnapshotDepth.Windows + let session = captured.Sessions |> Seq.find (fun s -> s.Name = "work-one") + match session.Windows |> Seq.tryFind (fun window -> window.Name = "tools") with + | Some window -> return window + | None -> + return! session.CreateWindowAsync( + NewWindowRequest(Name = "tools", Command = "/bin/cat"), token) + } + let! first = ensureTools () + let! second = ensureTools () + if first.Id <> second.Id then failwith "Created duplicate windows" + let! captured = server |> Server.capture token SnapshotDepth.Windows + let session = captured.Sessions |> Seq.find (fun s -> s.Name = "work-one") + if session.Windows.Count <> 2 then failwith "Expected editor and tools windows" + printfn "one tools window after two calls" +} + +[] +let main _ = + try + run().GetAwaiter().GetResult() + 0 + with error -> + eprintfn "%O" error + 1 +``` + +```console +$ dotnet build Query.fsproj --maxcpucount:1 -p:Example=ReuseLayout \ + -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath="$PWD/.packages" && + sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=ReuseLayout +``` + +Expected program output: + +```text +one tools window after two calls +``` + +## Decide what repeated setup means + +The reuse example preserves the existing window and its running processes. It does not reset its pane count, layout or commands. Choose that policy deliberately when building a reusable workspace command. + +This check-then-create sequence assumes one writer. Concurrent callers can both observe an absent window and create duplicates. Serialize setup in your application when several callers share a session. A later failure also leaves earlier successful mutations in place; tmux commands are not a transaction. + +Use [filtering and queries](../queries/) for stricter target selection and [captured handles](../server-session-window-pane/) to understand refresh behavior. diff --git a/site/src/content/docs/ports/kotlin/concepts/index.md b/site/src/content/docs/ports/kotlin/concepts/index.md new file mode 100644 index 00000000..946c015e --- /dev/null +++ b/site/src/content/docs/ports/kotlin/concepts/index.md @@ -0,0 +1,22 @@ +--- +port: kotlin +route: concepts +title: Kotlin concepts +description: Understand Kotlin handles, filters, transports and layout ownership. +sidebar: + label: Kotlin concepts + group: Concepts + order: 1 +tableOfContents: true +--- + +Use these concepts to reason about Kotlin handles, selection and command execution. Each page includes complete programs with imports, project files, run commands and cleanup. + +| Concept | What you will do | +| --- | --- | +| [Server, session, window, pane](./server-session-window-pane/) | Traverse a capture and refresh after a rename. | +| [Filtering and queries](./queries/) | Combine predicates, handle result counts and match related windows. | +| [Commands and control mode](./transports/) | Run bounded commands and manage a persistent control client. | +| [Layouts and repeated setup](./workspaces/) | Create a split window and reuse a named window safely. | + +For individual types and operations, open the [API reference](../reference/). For a first connection, start with [attaching to tmux](../guides/attaching-to-tmux/). diff --git a/site/src/content/docs/ports/kotlin/concepts/queries.md b/site/src/content/docs/ports/kotlin/concepts/queries.md new file mode 100644 index 00000000..12cdb13e --- /dev/null +++ b/site/src/content/docs/ports/kotlin/concepts/queries.md @@ -0,0 +1,248 @@ +--- +port: kotlin +route: concepts/queries +title: Filtering and queries +description: Compose Kotlin filters, distinguish zero and several matches, and query captured relations. +sidebar: + label: Filtering and queries + group: Concepts + order: 4 +tableOfContents: true +--- + +Filter captured objects in Kotlin, handle result counts explicitly, and match sessions by their related windows. Local filters read captured values; they do not subscribe to changes or issue a fresh tmux query. + +[`TextField`](../../reference/io-github-libtmux-kotlin-query-textfield/) builds typed expressions with `eq`, `ne`, `startsWith`, `endsWith`, `contains`, `oneOf` and `matches`. Compose them with `.and()`, `.or()` and `!`. Ordinary Kotlin predicates remain useful for application-specific conditions. + +## Setup and run + +Use an empty directory on Linux with Git, tmux 3.2a or newer, and JDK 25. Save the project files and launcher below, then save any complete program on this page. Each program has its own imports and entry point. + +The pinned source checkout supplies the Gradle wrapper and the library dependency. + +```kotlin title="settings.gradle.kts" +rootProject.name = "connect" +includeBuild("libtmux-source") +``` + +```kotlin title="build.gradle.kts" +val example = providers.gradleProperty("example").getOrElse("Local") + +plugins { + application + kotlin("jvm") version "2.4.10" +} + +repositories { mavenCentral() } +dependencies { + implementation("io.github.libtmux:libtmux-kotlin:0.0.1-alpha.17-SNAPSHOT") +} +kotlin { + jvmToolchain(25) + sourceSets.main { kotlin.srcDir("."); kotlin.include("${example}.kt") } +} +application { mainClass.set("${example}Kt") } +``` + +```properties title="gradle.properties" +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx2g +org.gradle.workers.max=2 +``` + +The launcher creates two sessions on a private socket: `work-one` with an `editor` window, and `work-two` with a `logs` window. Each pane runs `cat` so it stays alive. It stops only this server when the program finishes or fails. If shutdown fails, it reports the retained directory and exits with an error. + +```sh title="run.sh" +#!/bin/sh +set -eu + +binary=$(command -v tmux) +mkdir -p /tmp/libtmux-java-dev +directory=$(mktemp -d /tmp/libtmux-java-dev/query.XXXXXX) +socket="$directory/tmux.sock" + +cleanup() { + status=$? + trap - 0 HUP INT TERM + if [ -S "$socket" ] && ! "$binary" -S "$socket" kill-server; then + printf 'Cannot stop tmux; kept %s\n' "$directory" >&2 + exit 1 + fi + rm -rf "$directory" || exit 1 + exit "$status" +} +trap cleanup 0 +trap 'exit 1' HUP INT TERM + +unset TMUX TMUX_PANE +export LIBTMUX_SOCKET_PATH="$socket" TMUX_BIN="$binary" +"$binary" -S "$socket" -f /dev/null new-session -d -s work-one -n editor /bin/cat +"$binary" -S "$socket" new-session -d -s work-two -n logs /bin/cat +"$@" +"$binary" -S "$socket" has-session -t '=work-one' +``` + +Fetch the library revision used by these examples: + +```console +$ git clone https://github.com/libtmux/libtmux-java libtmux-source && + git -C libtmux-source checkout be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15 +``` + +Each run starts from the same two-session fixture. Programs do not depend on another example having run first. Their assertions fail if the observed result differs. + +## Match names and combine conditions + +Select the two names beginning with `work-`, then assert equality, AND, OR and exclusion against the same captured collection. Matching is case sensitive. The native collection predicate agrees with the typed prefix query. + +```kotlin title="Local.kt" +import io.github.libtmux.ServerConfig +import io.github.libtmux.ServerEndpoint +import io.github.libtmux.kotlin.* +import io.github.libtmux.kotlin.query.* +import java.nio.file.Path +import java.time.Duration +import kotlinx.coroutines.runBlocking + +fun main() = runBlocking { + val socket = requireNotNull(System.getenv("LIBTMUX_SOCKET_PATH")) { + "Set LIBTMUX_SOCKET_PATH to an existing socket" + } + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + withServer(config) { server -> + val sessions = server.sessions() + val matching = sessions.filter(Session.name startsWith "work-") + val names = matching.map { it.name }.sorted() + check(names == listOf("work-one", "work-two")) + println(names.joinToString(", ")) + // Filtering the captured list makes no new tmux calls. + check(sessions.filter { it.name.startsWith("work-") } == matching) + val onlyOne = (Session.name startsWith "work-").and(Session.name endsWith "one") + check(sessions.filter(onlyOne).single().name == "work-one") + val either = (Session.name eq "work-one").or(Session.name eq "work-two") + check(sessions.filter(either).size == 2) + check(sessions.filter(!(Session.name eq "work-one")).single().name == "work-two") + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=Local --console=plain --max-workers=2 +``` + +Expected program output: + +```text +work-one, work-two +``` + +## Distinguish missing and ambiguous results + +The program distinguishes zero and one local result. The checked server lookup throws `CardinalityException.MultipleMatches` for two matches. Do not use `singleOrNull()` when zero and several matches need different handling. + +```kotlin title="Cardinality.kt" +import io.github.libtmux.ServerConfig +import io.github.libtmux.ServerEndpoint +import io.github.libtmux.kotlin.* +import io.github.libtmux.kotlin.query.* +import java.nio.file.Path +import java.time.Duration +import kotlinx.coroutines.runBlocking + +fun main() = runBlocking { + val socket = requireNotNull(System.getenv("LIBTMUX_SOCKET_PATH")) { + "Set LIBTMUX_SOCKET_PATH to an existing socket" + } + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + withServer(config) { server -> + val sessions = server.sessions() + for (name in listOf("work-one", "missing")) { + val matches = sessions.filter(Session.name eq name) + when (matches.size) { + 0 -> println("$name: absent") + 1 -> println("$name: selected") + else -> error("More than one session named $name") + } + } + val ambiguous = sessions.filter(Session.name startsWith "work-") + check(ambiguous.size == 2) + // singleOrNull() alone cannot distinguish zero from several matches. + try { + server.session(Session.name startsWith "work-") + error("Expected an ambiguous selection") + } catch (error: io.github.libtmux.exception.CardinalityException.MultipleMatches) { + println("work-: ambiguous") + } + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=Cardinality --console=plain --max-workers=2 +``` + +Expected program output: + +```text +work-one: selected +missing: absent +work-: ambiguous +``` + +## Filter through related windows + +Match sessions with any window named `editor`, then match sessions with none. For an empty captured relation, `any` is false and `none` is true; `all` is true. An uncaptured relation is a different condition and must be captured before filtering. + +```kotlin title="Relations.kt" +import io.github.libtmux.ServerConfig +import io.github.libtmux.ServerEndpoint +import io.github.libtmux.kotlin.* +import io.github.libtmux.kotlin.query.* +import java.nio.file.Path +import java.time.Duration +import kotlinx.coroutines.runBlocking + +fun main() = runBlocking { + val socket = requireNotNull(System.getenv("LIBTMUX_SOCKET_PATH")) { + "Set LIBTMUX_SOCKET_PATH to an existing socket" + } + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + withServer(config) { server -> + val sessions = server.sessions() + val editorWindow = Window.name eq "editor" + val withEditor = sessions.filter(Session.windows any editorWindow) + val withoutEditor = sessions.filter(Session.windows none editorWindow) + check(withEditor.map { it.name } == listOf("work-one")) + check(withoutEditor.map { it.name } == listOf("work-two")) + println("editor: ${withEditor.single().name}") + println("no editor: ${withoutEditor.single().name}") + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=Relations --console=plain --max-workers=2 +``` + +Expected program output: + +```text +editor: work-one +no editor: work-two +``` + +## Refresh before repeating a decision + +Reusing the same list repeats the same decision over old state. Capture again when you need to observe a rename, new window or closed pane. The [snapshot example](../server-session-window-pane/#observe-a-rename-with-a-fresh-read) shows the old and fresh values side by side. diff --git a/site/src/content/docs/ports/kotlin/concepts/server-session-window-pane.md b/site/src/content/docs/ports/kotlin/concepts/server-session-window-pane.md new file mode 100644 index 00000000..c310a42c --- /dev/null +++ b/site/src/content/docs/ports/kotlin/concepts/server-session-window-pane.md @@ -0,0 +1,190 @@ +--- +port: kotlin +route: concepts/server-session-window-pane +title: Server, session, window, pane +description: Traverse captured Kotlin handles, retain IDs and refresh after changes. +sidebar: + label: Server, session, window, pane + group: Concepts + order: 2 +tableOfContents: true +--- + +Capture a hierarchy, then read its sessions, windows and panes locally. Handles retain the state they captured; a rename does not rewrite an older handle. Use a new capture to observe later changes. + +[`Server`](../../reference/io-github-libtmux-kotlin-server/) selects the socket and owns client resources. [`Session`](../../reference/io-github-libtmux-kotlin-session/), [`Window`](../../reference/io-github-libtmux-kotlin-window/) and [`Pane`](../../reference/io-github-libtmux-kotlin-pane/) hold captured state and send mutations through that server. The session list captures the hierarchy; walking its children reads that capture. + +A session holds window placements; a window holds panes. A linked window can appear in several sessions, so traversal counts placements rather than necessarily distinct physical windows. Names can change; retain IDs when identifying a target. + +## Setup and run + +Use an empty directory on Linux with Git, tmux 3.2a or newer, and JDK 25. Save the project files and launcher below, then save any complete program on this page. Each program has its own imports and entry point. + +The pinned source checkout supplies the Gradle wrapper and the library dependency. + +```kotlin title="settings.gradle.kts" +rootProject.name = "connect" +includeBuild("libtmux-source") +``` + +```kotlin title="build.gradle.kts" +val example = providers.gradleProperty("example").getOrElse("Local") + +plugins { + application + kotlin("jvm") version "2.4.10" +} + +repositories { mavenCentral() } +dependencies { + implementation("io.github.libtmux:libtmux-kotlin:0.0.1-alpha.17-SNAPSHOT") +} +kotlin { + jvmToolchain(25) + sourceSets.main { kotlin.srcDir("."); kotlin.include("${example}.kt") } +} +application { mainClass.set("${example}Kt") } +``` + +```properties title="gradle.properties" +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx2g +org.gradle.workers.max=2 +``` + +The launcher creates two sessions on a private socket: `work-one` with an `editor` window, and `work-two` with a `logs` window. Each pane runs `cat` so it stays alive. It stops only this server when the program finishes or fails. If shutdown fails, it reports the retained directory and exits with an error. + +```sh title="run.sh" +#!/bin/sh +set -eu + +binary=$(command -v tmux) +mkdir -p /tmp/libtmux-java-dev +directory=$(mktemp -d /tmp/libtmux-java-dev/query.XXXXXX) +socket="$directory/tmux.sock" + +cleanup() { + status=$? + trap - 0 HUP INT TERM + if [ -S "$socket" ] && ! "$binary" -S "$socket" kill-server; then + printf 'Cannot stop tmux; kept %s\n' "$directory" >&2 + exit 1 + fi + rm -rf "$directory" || exit 1 + exit "$status" +} +trap cleanup 0 +trap 'exit 1' HUP INT TERM + +unset TMUX TMUX_PANE +export LIBTMUX_SOCKET_PATH="$socket" TMUX_BIN="$binary" +"$binary" -S "$socket" -f /dev/null new-session -d -s work-one -n editor /bin/cat +"$binary" -S "$socket" new-session -d -s work-two -n logs /bin/cat +"$@" +"$binary" -S "$socket" has-session -t '=work-one' +``` + +Fetch the library revision used by these examples: + +```console +$ git clone https://github.com/libtmux/libtmux-java libtmux-source && + git -C libtmux-source checkout be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15 +``` + +Each run starts from the same two-session fixture. Programs do not depend on another example having run first. Their assertions fail if the observed result differs. + +## Walk sessions, windows and panes + +Flatten the captured children and verify the fixture contains two of each. The program also prints each session and its window. + +```kotlin title="Hierarchy.kt" +import io.github.libtmux.ServerConfig +import io.github.libtmux.ServerEndpoint +import io.github.libtmux.kotlin.* +import io.github.libtmux.kotlin.query.* +import java.nio.file.Path +import java.time.Duration +import kotlinx.coroutines.runBlocking + +fun main() = runBlocking { + val socket = requireNotNull(System.getenv("LIBTMUX_SOCKET_PATH")) { + "Set LIBTMUX_SOCKET_PATH to an existing socket" + } + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + withServer(config) { server -> + val sessions = server.sessions() + val windows = sessions.flatMap { it.windows } + val panes = windows.flatMap { it.panes } + check(sessions.size == 2 && windows.size == 2 && panes.size == 2) + for (session in sessions) { + println("${session.name}: ${session.windows.single().name}") + } + println("2 sessions, 2 windows, 2 panes") + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=Hierarchy --console=plain --max-workers=2 +``` + +Expected program output: + +```text +work-one: editor +work-two: logs +2 sessions, 2 windows, 2 panes +``` + +## Observe a rename with a fresh read + +Rename the editor window. The old handle still says `editor`; the returned handle and a new server read say `renamed`. This distinction matters when a UI, another client or your own code changes tmux after a capture. + +```kotlin title="Refresh.kt" +import io.github.libtmux.ServerConfig +import io.github.libtmux.ServerEndpoint +import io.github.libtmux.kotlin.* +import io.github.libtmux.kotlin.query.* +import java.nio.file.Path +import java.time.Duration +import kotlinx.coroutines.runBlocking + +fun main() = runBlocking { + val socket = requireNotNull(System.getenv("LIBTMUX_SOCKET_PATH")) { + "Set LIBTMUX_SOCKET_PATH to an existing socket" + } + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + withServer(config) { server -> + val session = server.sessions().single { it.name == "work-one" } + val before = session.windows.single() + val after = before.rename("renamed") + check(before.name == "editor") + check(after.name == "renamed") + val readAgain = server.sessions().single { it.name == "work-one" }.windows.single() + check(readAgain.name == "renamed") + println("${before.name} -> ${readAgain.name}") + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=Refresh --console=plain --max-workers=2 +``` + +Expected program output: + +```text +editor -> renamed +``` + +## Select a target before changing it + +A successful lookup does not reserve a tmux object. Another client can remove it before your next command. Handle a command failure at the mutation, and refresh before deciding what to do next. See [filtering and queries](../queries/) for missing and ambiguous selections, and [layouts](../workspaces/) for creation. diff --git a/site/src/content/docs/ports/kotlin/concepts/transports.md b/site/src/content/docs/ports/kotlin/concepts/transports.md new file mode 100644 index 00000000..665791ab --- /dev/null +++ b/site/src/content/docs/ports/kotlin/concepts/transports.md @@ -0,0 +1,195 @@ +--- +port: kotlin +route: concepts/transports +title: Commands and control mode +description: Use Kotlin command calls and persistent control connections with explicit cleanup. +sidebar: + label: Commands and control mode + group: Concepts + order: 5 +tableOfContents: true +--- + +Choose command calls for bounded operations and a control connection when you need a persistent tmux client. Closing a client releases its resources; it does not mean the tmux server should be stopped. + +[`withServer`](../../reference/io-github-libtmux-kotlin-withserver/) closes the borrowed client scope. Suspended command calls use the configured execution policy. A suspend function is not itself a persistent control connection; open one explicitly with [`withControl`](../../reference/io-github-libtmux-kotlin-withcontrol/). + +## Setup and run + +Use an empty directory on Linux with Git, tmux 3.2a or newer, and JDK 25. Save the project files and launcher below, then save any complete program on this page. Each program has its own imports and entry point. + +The pinned source checkout supplies the Gradle wrapper and the library dependency. + +```kotlin title="settings.gradle.kts" +rootProject.name = "connect" +includeBuild("libtmux-source") +``` + +```kotlin title="build.gradle.kts" +val example = providers.gradleProperty("example").getOrElse("Local") + +plugins { + application + kotlin("jvm") version "2.4.10" +} + +repositories { mavenCentral() } +dependencies { + implementation("io.github.libtmux:libtmux-kotlin:0.0.1-alpha.17-SNAPSHOT") +} +kotlin { + jvmToolchain(25) + sourceSets.main { kotlin.srcDir("."); kotlin.include("${example}.kt") } +} +application { mainClass.set("${example}Kt") } +``` + +```properties title="gradle.properties" +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx2g +org.gradle.workers.max=2 +``` + +The launcher creates two sessions on a private socket: `work-one` with an `editor` window, and `work-two` with a `logs` window. Each pane runs `cat` so it stays alive. It stops only this server when the program finishes or fails. If shutdown fails, it reports the retained directory and exits with an error. + +```sh title="run.sh" +#!/bin/sh +set -eu + +binary=$(command -v tmux) +mkdir -p /tmp/libtmux-java-dev +directory=$(mktemp -d /tmp/libtmux-java-dev/query.XXXXXX) +socket="$directory/tmux.sock" + +cleanup() { + status=$? + trap - 0 HUP INT TERM + if [ -S "$socket" ] && ! "$binary" -S "$socket" kill-server; then + printf 'Cannot stop tmux; kept %s\n' "$directory" >&2 + exit 1 + fi + rm -rf "$directory" || exit 1 + exit "$status" +} +trap cleanup 0 +trap 'exit 1' HUP INT TERM + +unset TMUX TMUX_PANE +export LIBTMUX_SOCKET_PATH="$socket" TMUX_BIN="$binary" +"$binary" -S "$socket" -f /dev/null new-session -d -s work-one -n editor /bin/cat +"$binary" -S "$socket" new-session -d -s work-two -n logs /bin/cat +"$@" +"$binary" -S "$socket" has-session -t '=work-one' +``` + +Fetch the library revision used by these examples: + +```console +$ git clone https://github.com/libtmux/libtmux-java libtmux-source && + git -C libtmux-source checkout be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15 +``` + +Each run starts from the same two-session fixture. Programs do not depend on another example having run first. Their assertions fail if the observed result differs. + +## Run a bounded command and inspect its result + +Read the session list and filter it locally. The connection uses a five-second timeout so an unavailable server fails visibly. + +```kotlin title="Local.kt" +import io.github.libtmux.ServerConfig +import io.github.libtmux.ServerEndpoint +import io.github.libtmux.kotlin.* +import io.github.libtmux.kotlin.query.* +import java.nio.file.Path +import java.time.Duration +import kotlinx.coroutines.runBlocking + +fun main() = runBlocking { + val socket = requireNotNull(System.getenv("LIBTMUX_SOCKET_PATH")) { + "Set LIBTMUX_SOCKET_PATH to an existing socket" + } + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + withServer(config) { server -> + val sessions = server.sessions() + val matching = sessions.filter(Session.name startsWith "work-") + val names = matching.map { it.name }.sorted() + check(names == listOf("work-one", "work-two")) + println(names.joinToString(", ")) + // Filtering the captured list makes no new tmux calls. + check(sessions.filter { it.name.startsWith("work-") } == matching) + val onlyOne = (Session.name startsWith "work-").and(Session.name endsWith "one") + check(sessions.filter(onlyOne).single().name == "work-one") + val either = (Session.name eq "work-one").or(Session.name eq "work-two") + check(sessions.filter(either).size == 2) + check(sessions.filter(!(Session.name eq "work-one")).single().name == "work-two") + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=Local --console=plain --max-workers=2 +``` + +Expected program output: + +```text +work-one, work-two +``` + +## Open and close a control client + +Send `list-sessions` over a persistent control connection, verify the response, then close the control client. A subsequent ordinary read verifies the tmux server still has both sessions. + +```kotlin title="Control.kt" +import io.github.libtmux.ServerConfig +import io.github.libtmux.ServerEndpoint +import io.github.libtmux.kotlin.* +import io.github.libtmux.kotlin.query.* +import java.nio.file.Path +import java.time.Duration +import kotlinx.coroutines.runBlocking + +fun main() = runBlocking { + val socket = requireNotNull(System.getenv("LIBTMUX_SOCKET_PATH")) { + "Set LIBTMUX_SOCKET_PATH to an existing socket" + } + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + withServer(config) { server -> + val session = server.sessions().single { it.name == "work-one" } + withControl(server, session) { control -> + val reply = control.send("list-sessions", "-F", "#{session_name}") + check(reply.succeeded()) { "tmux rejected list-sessions: ${reply.outcome()}" } + val names = reply.lines().sorted() + check(names == listOf("work-one", "work-two")) + println(names.joinToString(", ")) + } + check(server.sessions().size == 2) + println("control client closed; server still running") + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=Control --console=plain --max-workers=2 +``` + +Expected program output: + +```text +work-one, work-two +control client closed; server still running +``` + +## Timeouts and ownership + +An operation that times out may already have reached tmux. Read the current state before retrying a mutation such as creating a window. A cancellation signal does not roll back a completed tmux command. + +Here the launcher owns the private server and stops it after the program exits. An application connecting to an existing server should close its own client resources and leave that server running. See [attaching to tmux](../../guides/attaching-to-tmux/) for the connection-only example. diff --git a/site/src/content/docs/ports/kotlin/concepts/workspaces.md b/site/src/content/docs/ports/kotlin/concepts/workspaces.md new file mode 100644 index 00000000..c272442b --- /dev/null +++ b/site/src/content/docs/ports/kotlin/concepts/workspaces.md @@ -0,0 +1,194 @@ +--- +port: kotlin +route: concepts/workspaces +title: Layouts and repeated setup +description: Create and reuse Kotlin tmux layouts while preserving running processes. +sidebar: + label: Layouts and repeated setup + group: Concepts + order: 6 +tableOfContents: true +--- + +Build a tmux layout from Kotlin by creating a window, splitting a pane and selecting a layout. Capture again to inspect the result. The examples use the library APIs directly and give every pane a command that stays alive. + +## Setup and run + +Use an empty directory on Linux with Git, tmux 3.2a or newer, and JDK 25. Save the project files and launcher below, then save any complete program on this page. Each program has its own imports and entry point. + +The pinned source checkout supplies the Gradle wrapper and the library dependency. + +```kotlin title="settings.gradle.kts" +rootProject.name = "connect" +includeBuild("libtmux-source") +``` + +```kotlin title="build.gradle.kts" +val example = providers.gradleProperty("example").getOrElse("Local") + +plugins { + application + kotlin("jvm") version "2.4.10" +} + +repositories { mavenCentral() } +dependencies { + implementation("io.github.libtmux:libtmux-kotlin:0.0.1-alpha.17-SNAPSHOT") +} +kotlin { + jvmToolchain(25) + sourceSets.main { kotlin.srcDir("."); kotlin.include("${example}.kt") } +} +application { mainClass.set("${example}Kt") } +``` + +```properties title="gradle.properties" +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx2g +org.gradle.workers.max=2 +``` + +The launcher creates two sessions on a private socket: `work-one` with an `editor` window, and `work-two` with a `logs` window. Each pane runs `cat` so it stays alive. It stops only this server when the program finishes or fails. If shutdown fails, it reports the retained directory and exits with an error. + +```sh title="run.sh" +#!/bin/sh +set -eu + +binary=$(command -v tmux) +mkdir -p /tmp/libtmux-java-dev +directory=$(mktemp -d /tmp/libtmux-java-dev/query.XXXXXX) +socket="$directory/tmux.sock" + +cleanup() { + status=$? + trap - 0 HUP INT TERM + if [ -S "$socket" ] && ! "$binary" -S "$socket" kill-server; then + printf 'Cannot stop tmux; kept %s\n' "$directory" >&2 + exit 1 + fi + rm -rf "$directory" || exit 1 + exit "$status" +} +trap cleanup 0 +trap 'exit 1' HUP INT TERM + +unset TMUX TMUX_PANE +export LIBTMUX_SOCKET_PATH="$socket" TMUX_BIN="$binary" +"$binary" -S "$socket" -f /dev/null new-session -d -s work-one -n editor /bin/cat +"$binary" -S "$socket" new-session -d -s work-two -n logs /bin/cat +"$@" +"$binary" -S "$socket" has-session -t '=work-one' +``` + +Fetch the library revision used by these examples: + +```console +$ git clone https://github.com/libtmux/libtmux-java libtmux-source && + git -C libtmux-source checkout be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15 +``` + +Each run starts from the same two-session fixture. Programs do not depend on another example having run first. Their assertions fail if the observed result differs. + +## Create a two-pane tools window + +Create `tools` in `work-one`, split its pane to the right, then choose `even-horizontal`. The final read verifies two panes. The previously captured session does not automatically gain the new window. + +```kotlin title="Layout.kt" +import io.github.libtmux.ServerConfig +import io.github.libtmux.ServerEndpoint +import io.github.libtmux.kotlin.* +import io.github.libtmux.kotlin.query.* +import java.nio.file.Path +import java.time.Duration +import kotlinx.coroutines.runBlocking + +fun main() = runBlocking { + val socket = requireNotNull(System.getenv("LIBTMUX_SOCKET_PATH")) { + "Set LIBTMUX_SOCKET_PATH to an existing socket" + } + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + withServer(config) { server -> + val session = server.sessions().single { it.name == "work-one" } + val window = session.newWindow(io.github.libtmux.WindowSpec.builder() + .named("tools").running("/bin/cat").build()) + val original = window.panes.single() + original.split(io.github.libtmux.SplitSpec.builder().toRight().running("/bin/cat").build()) + window.selectLayout(io.github.libtmux.Layout.EVEN_HORIZONTAL) + val refreshed = server.sessions().single { it.name == "work-one" } + val tools = refreshed.windows.single { it.name == "tools" } + check(tools.panes.size == 2) + println("tools: 2 panes") + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=Layout --console=plain --max-workers=2 +``` + +Expected program output: + +```text +tools: 2 panes +``` + +## Reuse a named window + +Look for `tools` before creating it. Two sequential calls return the same window ID, and the session has only `editor` and `tools`. This is useful for a setup command you run repeatedly. + +```kotlin title="ReuseLayout.kt" +import io.github.libtmux.ServerConfig +import io.github.libtmux.ServerEndpoint +import io.github.libtmux.kotlin.* +import io.github.libtmux.kotlin.query.* +import java.nio.file.Path +import java.time.Duration +import kotlinx.coroutines.runBlocking + +fun main() = runBlocking { + val socket = requireNotNull(System.getenv("LIBTMUX_SOCKET_PATH")) { + "Set LIBTMUX_SOCKET_PATH to an existing socket" + } + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + withServer(config) { server -> + suspend fun ensureTools(): Window { + val session = server.sessions().single { it.name == "work-one" } + val existing = session.windows.singleOrNull { it.name == "tools" } + if (existing != null) return existing + return session.newWindow(io.github.libtmux.WindowSpec.builder() + .named("tools").running("/bin/cat").build()) + } + val first = ensureTools() + val second = ensureTools() + check(first.id == second.id) + check(server.sessions().single { it.name == "work-one" }.windows.size == 2) + println("one tools window after two calls") + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=ReuseLayout --console=plain --max-workers=2 +``` + +Expected program output: + +```text +one tools window after two calls +``` + +## Decide what repeated setup means + +The reuse example preserves the existing window and its running processes. It does not reset its pane count, layout or commands. Choose that policy deliberately when building a reusable workspace command. + +This check-then-create sequence assumes one writer. Concurrent callers can both observe an absent window and create duplicates. Serialize setup in your application when several callers share a session. A later failure also leaves earlier successful mutations in place; tmux commands are not a transaction. + +Use [filtering and queries](../queries/) for stricter target selection and [captured handles](../server-session-window-pane/) to understand refresh behavior. diff --git a/site/src/content/docs/ports/scala/concepts/index.md b/site/src/content/docs/ports/scala/concepts/index.md new file mode 100644 index 00000000..5a479db5 --- /dev/null +++ b/site/src/content/docs/ports/scala/concepts/index.md @@ -0,0 +1,22 @@ +--- +port: scala +route: concepts +title: Scala concepts +description: Understand Scala handles, filters, transports and layout ownership. +sidebar: + label: Scala concepts + group: Concepts + order: 1 +tableOfContents: true +--- + +Use these concepts to reason about Scala handles, selection and command execution. Each page includes complete programs with imports, project files, run commands and cleanup. + +| Concept | What you will do | +| --- | --- | +| [Server, session, window, pane](./server-session-window-pane/) | Traverse a capture and refresh after a rename. | +| [Filtering and queries](./queries/) | Combine predicates, handle result counts and match related windows. | +| [Commands and control mode](./transports/) | Run bounded commands and manage a persistent control client. | +| [Layouts and repeated setup](./workspaces/) | Create a split window and reuse a named window safely. | + +For individual types and operations, open the [API reference](../reference/). For a first connection, start with [attaching to tmux](../guides/attaching-to-tmux/). diff --git a/site/src/content/docs/ports/scala/concepts/queries.md b/site/src/content/docs/ports/scala/concepts/queries.md new file mode 100644 index 00000000..f9d7b467 --- /dev/null +++ b/site/src/content/docs/ports/scala/concepts/queries.md @@ -0,0 +1,249 @@ +--- +port: scala +route: concepts/queries +title: Filtering and queries +description: Compose Scala filters, distinguish zero and several matches, and query captured relations. +sidebar: + label: Filtering and queries + group: Concepts + order: 4 +tableOfContents: true +--- + +Filter captured objects in Scala, handle result counts explicitly, and match sessions by their related windows. Local filters read captured values; they do not subscribe to changes or issue a fresh tmux query. + +[`Expr`](../../reference/io-github-libtmux-scaladsl-query-expr-zfkn/) supports `&&`, `||` and `!`. The field companions expose equality, prefix, suffix, substring and pattern matching. Use `.matching(expression)` for a typed query or `.filter(predicate)` for an ordinary Scala condition. + +## Setup and run + +Use an empty directory on Linux with Git, tmux 3.2a or newer, and JDK 25. Save the project files and launcher below, then save any complete program on this page. Each program has its own imports and entry point. + +The pinned source checkout supplies the Gradle wrapper and the library dependency. + +```kotlin title="settings.gradle.kts" +rootProject.name = "connect" +includeBuild("libtmux-source") { + dependencySubstitution { + substitute(module("io.github.libtmux:libtmux-scala_3")) + .using(project(":libtmux-scala")) + } +} +``` + +```kotlin title="build.gradle.kts" +val example = providers.gradleProperty("example").getOrElse("Local") + +plugins { + application + scala +} + +repositories { mavenCentral() } +dependencies { + implementation("org.scala-lang:scala3-library_3:3.9.0") + implementation("io.github.libtmux:libtmux-scala_3:0.0.1-alpha.17-SNAPSHOT") +} +java { toolchain { languageVersion.set(JavaLanguageVersion.of(25)) } } +sourceSets.main { scala.srcDir("."); scala.include("${example}.scala") } +application { mainClass.set(example) } +``` + +```properties title="gradle.properties" +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx2g +org.gradle.workers.max=2 +``` + +The launcher creates two sessions on a private socket: `work-one` with an `editor` window, and `work-two` with a `logs` window. Each pane runs `cat` so it stays alive. It stops only this server when the program finishes or fails. If shutdown fails, it reports the retained directory and exits with an error. + +```sh title="run.sh" +#!/bin/sh +set -eu + +binary=$(command -v tmux) +mkdir -p /tmp/libtmux-java-dev +directory=$(mktemp -d /tmp/libtmux-java-dev/query.XXXXXX) +socket="$directory/tmux.sock" + +cleanup() { + status=$? + trap - 0 HUP INT TERM + if [ -S "$socket" ] && ! "$binary" -S "$socket" kill-server; then + printf 'Cannot stop tmux; kept %s\n' "$directory" >&2 + exit 1 + fi + rm -rf "$directory" || exit 1 + exit "$status" +} +trap cleanup 0 +trap 'exit 1' HUP INT TERM + +unset TMUX TMUX_PANE +export LIBTMUX_SOCKET_PATH="$socket" TMUX_BIN="$binary" +"$binary" -S "$socket" -f /dev/null new-session -d -s work-one -n editor /bin/cat +"$binary" -S "$socket" new-session -d -s work-two -n logs /bin/cat +"$@" +"$binary" -S "$socket" has-session -t '=work-one' +``` + +Fetch the library revision used by these examples: + +```console +$ git clone https://github.com/libtmux/libtmux-java libtmux-source && + git -C libtmux-source checkout be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15 +``` + +Each run starts from the same two-session fixture. Programs do not depend on another example having run first. Their assertions fail if the observed result differs. + +## Match names and combine conditions + +Select the two names beginning with `work-`, then assert equality, AND, OR and exclusion against the same captured collection. Matching is case sensitive. The native collection predicate agrees with the typed prefix query. + +```scala title="Local.scala" +import io.github.libtmux.{ServerConfig, ServerEndpoint} +import io.github.libtmux.scaladsl.* +import io.github.libtmux.scaladsl.query.* +import java.nio.file.Path +import java.time.Duration +import scala.util.Using +import scala.jdk.CollectionConverters.* + +object Local { + def main(args: Array[String]): Unit = { + val socket = sys.env.getOrElse("LIBTMUX_SOCKET_PATH", + throw new IllegalArgumentException("Set LIBTMUX_SOCKET_PATH to an existing socket")) + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + Using.resource(Server.open(config)) { server => + val sessions = server.sessions() + val matching = sessions.matching(Session.name.startsWith("work-")) + val names = matching.map(_.name).sorted + assert(names == Vector("work-one", "work-two")) + println(names.mkString(", ")) + // Filtering the captured vector makes no new tmux calls. + assert(sessions.filter(_.name.startsWith("work-")) == matching) + val onlyOne = Session.name.startsWith("work-") && Session.name.endsWith("one") + assert(sessions.matching(onlyOne).head.name == "work-one") + val either = Session.name.is("work-one") || Session.name.is("work-two") + assert(sessions.matching(either).size == 2) + assert(sessions.matching(!Session.name.is("work-one")).head.name == "work-two") + } + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=Local --console=plain --max-workers=2 +``` + +Expected program output: + +```text +work-one, work-two +``` + +## Distinguish missing and ambiguous results + +An exactly-one selection returns an `Either`: one handle on success, `CardinalityError.NoMatch` for zero, or `MultipleMatches` for several. An at-most-one selection also rejects ambiguity; it does not pick an arbitrary first result. + +```scala title="Cardinality.scala" +import io.github.libtmux.{ServerConfig, ServerEndpoint} +import io.github.libtmux.scaladsl.* +import io.github.libtmux.scaladsl.query.* +import java.nio.file.Path +import java.time.Duration +import scala.util.Using +import scala.jdk.CollectionConverters.* + +object Cardinality { + def main(args: Array[String]): Unit = { + val socket = sys.env.getOrElse("LIBTMUX_SOCKET_PATH", + throw new IllegalArgumentException("Set LIBTMUX_SOCKET_PATH to an existing socket")) + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + Using.resource(Server.open(config)) { server => + val sessions = server.sessions() + for (name <- Vector("work-one", "missing")) { + sessions.matching(Session.name.is(name)).exactlyOne match { + case Right(session) => println(s"${session.name}: selected") + case Left(CardinalityError.NoMatch) => println(s"$name: absent") + case Left(CardinalityError.MultipleMatches(count)) => + throw new IllegalStateException(s"At least $count sessions named $name") + } + } + val many = sessions.matching(Session.name.startsWith("work-")).atMostOne + assert(many == Left(CardinalityError.MultipleMatches(2))) + println("work-: ambiguous") + } + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=Cardinality --console=plain --max-workers=2 +``` + +Expected program output: + +```text +work-one: selected +missing: absent +work-: ambiguous +``` + +## Filter through related windows + +Match sessions with any window named `editor`, then match sessions with none. For an empty captured relation, `any` is false and `none` is true; `all` is true. An uncaptured relation is a different condition and must be captured before filtering. + +```scala title="Relations.scala" +import io.github.libtmux.{ServerConfig, ServerEndpoint} +import io.github.libtmux.scaladsl.* +import io.github.libtmux.scaladsl.query.* +import java.nio.file.Path +import java.time.Duration +import scala.util.Using +import scala.jdk.CollectionConverters.* + +object Relations { + def main(args: Array[String]): Unit = { + val socket = sys.env.getOrElse("LIBTMUX_SOCKET_PATH", + throw new IllegalArgumentException("Set LIBTMUX_SOCKET_PATH to an existing socket")) + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + Using.resource(Server.open(config)) { server => + val sessions = server.sessions() + val editorWindow = Window.name.is("editor") + val withEditor = sessions.matching(Session.windows.any(editorWindow)) + val withoutEditor = sessions.matching(Session.windows.none(editorWindow)) + assert(withEditor.map(_.name) == Vector("work-one")) + assert(withoutEditor.map(_.name) == Vector("work-two")) + println(s"editor: ${withEditor.head.name}") + println(s"no editor: ${withoutEditor.head.name}") + } + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=Relations --console=plain --max-workers=2 +``` + +Expected program output: + +```text +editor: work-one +no editor: work-two +``` + +## Refresh before repeating a decision + +Reusing the same list repeats the same decision over old state. Capture again when you need to observe a rename, new window or closed pane. The [snapshot example](../server-session-window-pane/#observe-a-rename-with-a-fresh-read) shows the old and fresh values side by side. diff --git a/site/src/content/docs/ports/scala/concepts/server-session-window-pane.md b/site/src/content/docs/ports/scala/concepts/server-session-window-pane.md new file mode 100644 index 00000000..59ab9d55 --- /dev/null +++ b/site/src/content/docs/ports/scala/concepts/server-session-window-pane.md @@ -0,0 +1,196 @@ +--- +port: scala +route: concepts/server-session-window-pane +title: Server, session, window, pane +description: Traverse captured Scala handles, retain IDs and refresh after changes. +sidebar: + label: Server, session, window, pane + group: Concepts + order: 2 +tableOfContents: true +--- + +Capture a hierarchy, then read its sessions, windows and panes locally. Handles retain the state they captured; a rename does not rewrite an older handle. Use a new capture to observe later changes. + +[`Server`](../../reference/io-github-libtmux-scaladsl-server/) selects the socket and owns client resources. [`Session`](../../reference/io-github-libtmux-scaladsl-session/), [`Window`](../../reference/io-github-libtmux-scaladsl-window/) and [`Pane`](../../reference/io-github-libtmux-scaladsl-pane/) hold captured state and send mutations through that server. The session list captures the hierarchy; walking its children reads that capture. + +A session holds window placements; a window holds panes. A linked window can appear in several sessions, so traversal counts placements rather than necessarily distinct physical windows. Names can change; retain IDs when identifying a target. + +## Setup and run + +Use an empty directory on Linux with Git, tmux 3.2a or newer, and JDK 25. Save the project files and launcher below, then save any complete program on this page. Each program has its own imports and entry point. + +The pinned source checkout supplies the Gradle wrapper and the library dependency. + +```kotlin title="settings.gradle.kts" +rootProject.name = "connect" +includeBuild("libtmux-source") { + dependencySubstitution { + substitute(module("io.github.libtmux:libtmux-scala_3")) + .using(project(":libtmux-scala")) + } +} +``` + +```kotlin title="build.gradle.kts" +val example = providers.gradleProperty("example").getOrElse("Local") + +plugins { + application + scala +} + +repositories { mavenCentral() } +dependencies { + implementation("org.scala-lang:scala3-library_3:3.9.0") + implementation("io.github.libtmux:libtmux-scala_3:0.0.1-alpha.17-SNAPSHOT") +} +java { toolchain { languageVersion.set(JavaLanguageVersion.of(25)) } } +sourceSets.main { scala.srcDir("."); scala.include("${example}.scala") } +application { mainClass.set(example) } +``` + +```properties title="gradle.properties" +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx2g +org.gradle.workers.max=2 +``` + +The launcher creates two sessions on a private socket: `work-one` with an `editor` window, and `work-two` with a `logs` window. Each pane runs `cat` so it stays alive. It stops only this server when the program finishes or fails. If shutdown fails, it reports the retained directory and exits with an error. + +```sh title="run.sh" +#!/bin/sh +set -eu + +binary=$(command -v tmux) +mkdir -p /tmp/libtmux-java-dev +directory=$(mktemp -d /tmp/libtmux-java-dev/query.XXXXXX) +socket="$directory/tmux.sock" + +cleanup() { + status=$? + trap - 0 HUP INT TERM + if [ -S "$socket" ] && ! "$binary" -S "$socket" kill-server; then + printf 'Cannot stop tmux; kept %s\n' "$directory" >&2 + exit 1 + fi + rm -rf "$directory" || exit 1 + exit "$status" +} +trap cleanup 0 +trap 'exit 1' HUP INT TERM + +unset TMUX TMUX_PANE +export LIBTMUX_SOCKET_PATH="$socket" TMUX_BIN="$binary" +"$binary" -S "$socket" -f /dev/null new-session -d -s work-one -n editor /bin/cat +"$binary" -S "$socket" new-session -d -s work-two -n logs /bin/cat +"$@" +"$binary" -S "$socket" has-session -t '=work-one' +``` + +Fetch the library revision used by these examples: + +```console +$ git clone https://github.com/libtmux/libtmux-java libtmux-source && + git -C libtmux-source checkout be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15 +``` + +Each run starts from the same two-session fixture. Programs do not depend on another example having run first. Their assertions fail if the observed result differs. + +## Walk sessions, windows and panes + +Flatten the captured children and verify the fixture contains two of each. The program also prints each session and its window. + +```scala title="Hierarchy.scala" +import io.github.libtmux.{ServerConfig, ServerEndpoint} +import io.github.libtmux.scaladsl.* +import io.github.libtmux.scaladsl.query.* +import java.nio.file.Path +import java.time.Duration +import scala.util.Using +import scala.jdk.CollectionConverters.* + +object Hierarchy { + def main(args: Array[String]): Unit = { + val socket = sys.env.getOrElse("LIBTMUX_SOCKET_PATH", + throw new IllegalArgumentException("Set LIBTMUX_SOCKET_PATH to an existing socket")) + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + Using.resource(Server.open(config)) { server => + val sessions = server.sessions() + val windows = sessions.flatMap(_.windows) + val panes = windows.flatMap(_.panes) + assert(sessions.size == 2 && windows.size == 2 && panes.size == 2) + for (session <- sessions) { + println(s"${session.name}: ${session.windows.head.name}") + } + println("2 sessions, 2 windows, 2 panes") + } + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=Hierarchy --console=plain --max-workers=2 +``` + +Expected program output: + +```text +work-one: editor +work-two: logs +2 sessions, 2 windows, 2 panes +``` + +## Observe a rename with a fresh read + +Rename the editor window. The old handle still says `editor`; the returned handle and a new server read say `renamed`. This distinction matters when a UI, another client or your own code changes tmux after a capture. + +```scala title="Refresh.scala" +import io.github.libtmux.{ServerConfig, ServerEndpoint} +import io.github.libtmux.scaladsl.* +import io.github.libtmux.scaladsl.query.* +import java.nio.file.Path +import java.time.Duration +import scala.util.Using +import scala.jdk.CollectionConverters.* + +object Refresh { + def main(args: Array[String]): Unit = { + val socket = sys.env.getOrElse("LIBTMUX_SOCKET_PATH", + throw new IllegalArgumentException("Set LIBTMUX_SOCKET_PATH to an existing socket")) + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + Using.resource(Server.open(config)) { server => + val session = server.sessions().find(_.name == "work-one").get + val before = session.windows.head + val after = before.rename("renamed") + assert(before.name == "editor") + assert(after.name == "renamed") + val readAgain = server.sessions().find(_.name == "work-one").get.windows.head + assert(readAgain.name == "renamed") + println(s"${before.name} -> ${readAgain.name}") + } + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=Refresh --console=plain --max-workers=2 +``` + +Expected program output: + +```text +editor -> renamed +``` + +## Select a target before changing it + +A successful lookup does not reserve a tmux object. Another client can remove it before your next command. Handle a command failure at the mutation, and refresh before deciding what to do next. See [filtering and queries](../queries/) for missing and ambiguous selections, and [layouts](../workspaces/) for creation. diff --git a/site/src/content/docs/ports/scala/concepts/transports.md b/site/src/content/docs/ports/scala/concepts/transports.md new file mode 100644 index 00000000..1e1b6ec8 --- /dev/null +++ b/site/src/content/docs/ports/scala/concepts/transports.md @@ -0,0 +1,201 @@ +--- +port: scala +route: concepts/transports +title: Commands and control mode +description: Use Scala command calls and persistent control connections with explicit cleanup. +sidebar: + label: Commands and control mode + group: Concepts + order: 5 +tableOfContents: true +--- + +Choose command calls for bounded operations and a control connection when you need a persistent tmux client. Closing a client releases its resources; it does not mean the tmux server should be stopped. + +The direct [`Server`](../../reference/io-github-libtmux-scaladsl-server/) API blocks until the operation completes. `Using.resource` closes its client resources. For effectful applications, the [execution guide](../../guides/execution/) covers the Cats Effect and Ox packages. + +## Setup and run + +Use an empty directory on Linux with Git, tmux 3.2a or newer, and JDK 25. Save the project files and launcher below, then save any complete program on this page. Each program has its own imports and entry point. + +The pinned source checkout supplies the Gradle wrapper and the library dependency. + +```kotlin title="settings.gradle.kts" +rootProject.name = "connect" +includeBuild("libtmux-source") { + dependencySubstitution { + substitute(module("io.github.libtmux:libtmux-scala_3")) + .using(project(":libtmux-scala")) + } +} +``` + +```kotlin title="build.gradle.kts" +val example = providers.gradleProperty("example").getOrElse("Local") + +plugins { + application + scala +} + +repositories { mavenCentral() } +dependencies { + implementation("org.scala-lang:scala3-library_3:3.9.0") + implementation("io.github.libtmux:libtmux-scala_3:0.0.1-alpha.17-SNAPSHOT") +} +java { toolchain { languageVersion.set(JavaLanguageVersion.of(25)) } } +sourceSets.main { scala.srcDir("."); scala.include("${example}.scala") } +application { mainClass.set(example) } +``` + +```properties title="gradle.properties" +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx2g +org.gradle.workers.max=2 +``` + +The launcher creates two sessions on a private socket: `work-one` with an `editor` window, and `work-two` with a `logs` window. Each pane runs `cat` so it stays alive. It stops only this server when the program finishes or fails. If shutdown fails, it reports the retained directory and exits with an error. + +```sh title="run.sh" +#!/bin/sh +set -eu + +binary=$(command -v tmux) +mkdir -p /tmp/libtmux-java-dev +directory=$(mktemp -d /tmp/libtmux-java-dev/query.XXXXXX) +socket="$directory/tmux.sock" + +cleanup() { + status=$? + trap - 0 HUP INT TERM + if [ -S "$socket" ] && ! "$binary" -S "$socket" kill-server; then + printf 'Cannot stop tmux; kept %s\n' "$directory" >&2 + exit 1 + fi + rm -rf "$directory" || exit 1 + exit "$status" +} +trap cleanup 0 +trap 'exit 1' HUP INT TERM + +unset TMUX TMUX_PANE +export LIBTMUX_SOCKET_PATH="$socket" TMUX_BIN="$binary" +"$binary" -S "$socket" -f /dev/null new-session -d -s work-one -n editor /bin/cat +"$binary" -S "$socket" new-session -d -s work-two -n logs /bin/cat +"$@" +"$binary" -S "$socket" has-session -t '=work-one' +``` + +Fetch the library revision used by these examples: + +```console +$ git clone https://github.com/libtmux/libtmux-java libtmux-source && + git -C libtmux-source checkout be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15 +``` + +Each run starts from the same two-session fixture. Programs do not depend on another example having run first. Their assertions fail if the observed result differs. + +## Run a bounded command and inspect its result + +Read the session list and filter it locally. The connection uses a five-second timeout so an unavailable server fails visibly. + +```scala title="Local.scala" +import io.github.libtmux.{ServerConfig, ServerEndpoint} +import io.github.libtmux.scaladsl.* +import io.github.libtmux.scaladsl.query.* +import java.nio.file.Path +import java.time.Duration +import scala.util.Using +import scala.jdk.CollectionConverters.* + +object Local { + def main(args: Array[String]): Unit = { + val socket = sys.env.getOrElse("LIBTMUX_SOCKET_PATH", + throw new IllegalArgumentException("Set LIBTMUX_SOCKET_PATH to an existing socket")) + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + Using.resource(Server.open(config)) { server => + val sessions = server.sessions() + val matching = sessions.matching(Session.name.startsWith("work-")) + val names = matching.map(_.name).sorted + assert(names == Vector("work-one", "work-two")) + println(names.mkString(", ")) + // Filtering the captured vector makes no new tmux calls. + assert(sessions.filter(_.name.startsWith("work-")) == matching) + val onlyOne = Session.name.startsWith("work-") && Session.name.endsWith("one") + assert(sessions.matching(onlyOne).head.name == "work-one") + val either = Session.name.is("work-one") || Session.name.is("work-two") + assert(sessions.matching(either).size == 2) + assert(sessions.matching(!Session.name.is("work-one")).head.name == "work-two") + } + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=Local --console=plain --max-workers=2 +``` + +Expected program output: + +```text +work-one, work-two +``` + +## Open and close a control client + +Send `list-sessions` over a persistent control connection, verify the response, then close the control client. A subsequent ordinary read verifies the tmux server still has both sessions. + +```scala title="Control.scala" +import io.github.libtmux.{ServerConfig, ServerEndpoint} +import io.github.libtmux.scaladsl.* +import io.github.libtmux.scaladsl.query.* +import java.nio.file.Path +import java.time.Duration +import scala.util.Using +import scala.jdk.CollectionConverters.* + +object Control { + def main(args: Array[String]): Unit = { + val socket = sys.env.getOrElse("LIBTMUX_SOCKET_PATH", + throw new IllegalArgumentException("Set LIBTMUX_SOCKET_PATH to an existing socket")) + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + Using.resource(Server.open(config)) { server => + val session = server.sessions().find(_.name == "work-one").get + Using.resource(server.control(session)) { control => + val reply = control.send("list-sessions", "-F", "#{session_name}") + require(reply.succeeded(), s"tmux rejected list-sessions: ${reply.outcome()}") + val names = reply.lines().asScala.toVector.sorted + assert(names == Vector("work-one", "work-two")) + println(names.mkString(", ")) + } + assert(server.sessions().size == 2) + println("control client closed; server still running") + } + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=Control --console=plain --max-workers=2 +``` + +Expected program output: + +```text +work-one, work-two +control client closed; server still running +``` + +## Timeouts and ownership + +An operation that times out may already have reached tmux. Read the current state before retrying a mutation such as creating a window. A cancellation signal does not roll back a completed tmux command. + +Here the launcher owns the private server and stops it after the program exits. An application connecting to an existing server should close its own client resources and leave that server running. See [attaching to tmux](../../guides/attaching-to-tmux/) for the connection-only example. diff --git a/site/src/content/docs/ports/scala/concepts/workspaces.md b/site/src/content/docs/ports/scala/concepts/workspaces.md new file mode 100644 index 00000000..5b4fe582 --- /dev/null +++ b/site/src/content/docs/ports/scala/concepts/workspaces.md @@ -0,0 +1,200 @@ +--- +port: scala +route: concepts/workspaces +title: Layouts and repeated setup +description: Create and reuse Scala tmux layouts while preserving running processes. +sidebar: + label: Layouts and repeated setup + group: Concepts + order: 6 +tableOfContents: true +--- + +Build a tmux layout from Scala by creating a window, splitting a pane and selecting a layout. Capture again to inspect the result. The examples use the library APIs directly and give every pane a command that stays alive. + +## Setup and run + +Use an empty directory on Linux with Git, tmux 3.2a or newer, and JDK 25. Save the project files and launcher below, then save any complete program on this page. Each program has its own imports and entry point. + +The pinned source checkout supplies the Gradle wrapper and the library dependency. + +```kotlin title="settings.gradle.kts" +rootProject.name = "connect" +includeBuild("libtmux-source") { + dependencySubstitution { + substitute(module("io.github.libtmux:libtmux-scala_3")) + .using(project(":libtmux-scala")) + } +} +``` + +```kotlin title="build.gradle.kts" +val example = providers.gradleProperty("example").getOrElse("Local") + +plugins { + application + scala +} + +repositories { mavenCentral() } +dependencies { + implementation("org.scala-lang:scala3-library_3:3.9.0") + implementation("io.github.libtmux:libtmux-scala_3:0.0.1-alpha.17-SNAPSHOT") +} +java { toolchain { languageVersion.set(JavaLanguageVersion.of(25)) } } +sourceSets.main { scala.srcDir("."); scala.include("${example}.scala") } +application { mainClass.set(example) } +``` + +```properties title="gradle.properties" +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx2g +org.gradle.workers.max=2 +``` + +The launcher creates two sessions on a private socket: `work-one` with an `editor` window, and `work-two` with a `logs` window. Each pane runs `cat` so it stays alive. It stops only this server when the program finishes or fails. If shutdown fails, it reports the retained directory and exits with an error. + +```sh title="run.sh" +#!/bin/sh +set -eu + +binary=$(command -v tmux) +mkdir -p /tmp/libtmux-java-dev +directory=$(mktemp -d /tmp/libtmux-java-dev/query.XXXXXX) +socket="$directory/tmux.sock" + +cleanup() { + status=$? + trap - 0 HUP INT TERM + if [ -S "$socket" ] && ! "$binary" -S "$socket" kill-server; then + printf 'Cannot stop tmux; kept %s\n' "$directory" >&2 + exit 1 + fi + rm -rf "$directory" || exit 1 + exit "$status" +} +trap cleanup 0 +trap 'exit 1' HUP INT TERM + +unset TMUX TMUX_PANE +export LIBTMUX_SOCKET_PATH="$socket" TMUX_BIN="$binary" +"$binary" -S "$socket" -f /dev/null new-session -d -s work-one -n editor /bin/cat +"$binary" -S "$socket" new-session -d -s work-two -n logs /bin/cat +"$@" +"$binary" -S "$socket" has-session -t '=work-one' +``` + +Fetch the library revision used by these examples: + +```console +$ git clone https://github.com/libtmux/libtmux-java libtmux-source && + git -C libtmux-source checkout be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15 +``` + +Each run starts from the same two-session fixture. Programs do not depend on another example having run first. Their assertions fail if the observed result differs. + +## Create a two-pane tools window + +Create `tools` in `work-one`, split its pane to the right, then choose `even-horizontal`. The final read verifies two panes. The previously captured session does not automatically gain the new window. + +```scala title="Layout.scala" +import io.github.libtmux.{ServerConfig, ServerEndpoint} +import io.github.libtmux.scaladsl.* +import io.github.libtmux.scaladsl.query.* +import java.nio.file.Path +import java.time.Duration +import scala.util.Using +import scala.jdk.CollectionConverters.* + +object Layout { + def main(args: Array[String]): Unit = { + val socket = sys.env.getOrElse("LIBTMUX_SOCKET_PATH", + throw new IllegalArgumentException("Set LIBTMUX_SOCKET_PATH to an existing socket")) + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + Using.resource(Server.open(config)) { server => + val session = server.sessions().find(_.name == "work-one").get + val window = session.newWindow(io.github.libtmux.WindowSpec.builder() + .named("tools").running("/bin/cat").build()) + window.panes.head.split(io.github.libtmux.SplitSpec.builder() + .toRight().running("/bin/cat").build()) + window.selectLayout(io.github.libtmux.Layout.EVEN_HORIZONTAL) + val refreshed = server.sessions().find(_.name == "work-one").get + val tools = refreshed.windows.find(_.name == "tools").get + assert(tools.panes.size == 2) + println("tools: 2 panes") + } + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=Layout --console=plain --max-workers=2 +``` + +Expected program output: + +```text +tools: 2 panes +``` + +## Reuse a named window + +Look for `tools` before creating it. Two sequential calls return the same window ID, and the session has only `editor` and `tools`. This is useful for a setup command you run repeatedly. + +```scala title="ReuseLayout.scala" +import io.github.libtmux.{ServerConfig, ServerEndpoint} +import io.github.libtmux.scaladsl.* +import io.github.libtmux.scaladsl.query.* +import java.nio.file.Path +import java.time.Duration +import scala.util.Using +import scala.jdk.CollectionConverters.* + +object ReuseLayout { + def main(args: Array[String]): Unit = { + val socket = sys.env.getOrElse("LIBTMUX_SOCKET_PATH", + throw new IllegalArgumentException("Set LIBTMUX_SOCKET_PATH to an existing socket")) + val config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(Path.of(socket))) + .defaultTimeout(Duration.ofSeconds(5)) + .build() + Using.resource(Server.open(config)) { server => + def ensureTools(): Window = { + val session = server.sessions().find(_.name == "work-one").get + session.windows.find(_.name == "tools").getOrElse { + session.newWindow(io.github.libtmux.WindowSpec.builder() + .named("tools").running("/bin/cat").build()) + } + } + val first = ensureTools() + val second = ensureTools() + assert(first.id == second.id) + assert(server.sessions().find(_.name == "work-one").get.windows.size == 2) + println("one tools window after two calls") + } + } +} +``` + +```console +$ sh run.sh ./libtmux-source/gradlew --project-dir . run \ + -Pexample=ReuseLayout --console=plain --max-workers=2 +``` + +Expected program output: + +```text +one tools window after two calls +``` + +## Decide what repeated setup means + +The reuse example preserves the existing window and its running processes. It does not reset its pane count, layout or commands. Choose that policy deliberately when building a reusable workspace command. + +This check-then-create sequence assumes one writer. Concurrent callers can both observe an absent window and create duplicates. Serialize setup in your application when several callers share a session. A later failure also leaves earlier successful mutations in place; tmux commands are not a transaction. + +Use [filtering and queries](../queries/) for stricter target selection and [captured handles](../server-session-window-pane/) to understand refresh behavior. diff --git a/site/src/data/mentions.json b/site/src/data/mentions.json index c1c0b163..680bbe73 100644 --- a/site/src/data/mentions.json +++ b/site/src/data/mentions.json @@ -1477,6 +1477,13 @@ "title": "F# API reference", "section": "guides" }, + { + "port": "fsharp", + "symbol": "LibTmux.FSharp.CardinalityError.MultipleMatches", + "page": "/fsharp/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, { "port": "fsharp", "symbol": "LibTmux.FSharp.CardinalityError.MultipleMatches", @@ -1484,6 +1491,13 @@ "title": "F# API reference", "section": "guides" }, + { + "port": "fsharp", + "symbol": "LibTmux.FSharp.CardinalityError.NoMatches", + "page": "/fsharp/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, { "port": "fsharp", "symbol": "LibTmux.FSharp.CardinalityError.NoMatches", @@ -1561,6 +1575,13 @@ "title": "Streams and cleanup", "section": "guides" }, + { + "port": "fsharp", + "symbol": "LibTmux.FSharp.Control.withSession", + "page": "/fsharp/latest/concepts/transports/", + "title": "Commands and control mode", + "section": "concepts" + }, { "port": "fsharp", "symbol": "LibTmux.FSharp.Control.withSession", @@ -1575,6 +1596,13 @@ "title": "Streams and cleanup", "section": "guides" }, + { + "port": "fsharp", + "symbol": "LibTmux.FSharp.Filter", + "page": "/fsharp/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, { "port": "fsharp", "symbol": "LibTmux.FSharp.Filter", @@ -1596,6 +1624,13 @@ "title": "F# portable query fields", "section": "guides" }, + { + "port": "fsharp", + "symbol": "LibTmux.FSharp.Filter.all", + "page": "/fsharp/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, { "port": "fsharp", "symbol": "LibTmux.FSharp.Filter.all", @@ -1603,6 +1638,13 @@ "title": "F# portable query fields", "section": "guides" }, + { + "port": "fsharp", + "symbol": "LibTmux.FSharp.Filter.allOf", + "page": "/fsharp/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, { "port": "fsharp", "symbol": "LibTmux.FSharp.Filter.allOf", @@ -1610,6 +1652,13 @@ "title": "F# portable query fields", "section": "guides" }, + { + "port": "fsharp", + "symbol": "LibTmux.FSharp.Filter.any", + "page": "/fsharp/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, { "port": "fsharp", "symbol": "LibTmux.FSharp.Filter.any", @@ -1624,6 +1673,13 @@ "title": "F# portable query fields", "section": "guides" }, + { + "port": "fsharp", + "symbol": "LibTmux.FSharp.Filter.anyOf", + "page": "/fsharp/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, { "port": "fsharp", "symbol": "LibTmux.FSharp.Filter.anyOf", @@ -1645,6 +1701,20 @@ "title": "F# portable query fields", "section": "guides" }, + { + "port": "fsharp", + "symbol": "LibTmux.FSharp.Filter.negate", + "page": "/fsharp/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, + { + "port": "fsharp", + "symbol": "LibTmux.FSharp.Filter.none", + "page": "/fsharp/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, { "port": "fsharp", "symbol": "LibTmux.FSharp.Filter.none", @@ -1729,6 +1799,13 @@ "title": "F# API reference", "section": "guides" }, + { + "port": "fsharp", + "symbol": "LibTmux.FSharp.Query.matching", + "page": "/fsharp/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, { "port": "fsharp", "symbol": "LibTmux.FSharp.Query.matching", @@ -1757,6 +1834,13 @@ "title": "F# API reference", "section": "guides" }, + { + "port": "fsharp", + "symbol": "LibTmux.FSharp.Selection.exactlyOne", + "page": "/fsharp/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, { "port": "fsharp", "symbol": "LibTmux.FSharp.Selection.exactlyOne", @@ -1771,6 +1855,13 @@ "title": "LibTmux.FSharp", "section": "guides" }, + { + "port": "fsharp", + "symbol": "LibTmux.FSharp.Server", + "page": "/fsharp/latest/concepts/server-session-window-pane/", + "title": "Server, session, window, pane", + "section": "concepts" + }, { "port": "fsharp", "symbol": "LibTmux.FSharp.Server", @@ -1792,6 +1883,13 @@ "title": ".NET interoperation", "section": "guides" }, + { + "port": "fsharp", + "symbol": "LibTmux.FSharp.Server.capture", + "page": "/fsharp/latest/concepts/transports/", + "title": "Commands and control mode", + "section": "concepts" + }, { "port": "fsharp", "symbol": "LibTmux.FSharp.Server.capture", @@ -3570,6 +3668,13 @@ "title": "Kotlin", "section": "guides" }, + { + "port": "kotlin", + "symbol": "io.github.libtmux.kotlin.Pane", + "page": "/kotlin/latest/concepts/server-session-window-pane/", + "title": "Server, session, window, pane", + "section": "concepts" + }, { "port": "kotlin", "symbol": "io.github.libtmux.kotlin.Pane", @@ -3626,6 +3731,62 @@ "title": "libtmux-kotlin", "section": "guides" }, + { + "port": "kotlin", + "symbol": "io.github.libtmux.kotlin.query.TextField", + "page": "/kotlin/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, + { + "port": "kotlin", + "symbol": "io.github.libtmux.kotlin.query.TextField.contains", + "page": "/kotlin/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, + { + "port": "kotlin", + "symbol": "io.github.libtmux.kotlin.query.TextField.endsWith", + "page": "/kotlin/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, + { + "port": "kotlin", + "symbol": "io.github.libtmux.kotlin.query.TextField.matches", + "page": "/kotlin/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, + { + "port": "kotlin", + "symbol": "io.github.libtmux.kotlin.query.TextField.oneOf", + "page": "/kotlin/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, + { + "port": "kotlin", + "symbol": "io.github.libtmux.kotlin.query.TextField.startsWith", + "page": "/kotlin/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, + { + "port": "kotlin", + "symbol": "io.github.libtmux.kotlin.query.ToManyField.any", + "page": "/kotlin/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, + { + "port": "kotlin", + "symbol": "io.github.libtmux.kotlin.query.ToManyField.none", + "page": "/kotlin/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, { "port": "kotlin", "symbol": "io.github.libtmux.kotlin.retryIfSafe", @@ -3633,6 +3794,13 @@ "title": "libtmux-kotlin", "section": "guides" }, + { + "port": "kotlin", + "symbol": "io.github.libtmux.kotlin.Server", + "page": "/kotlin/latest/concepts/server-session-window-pane/", + "title": "Server, session, window, pane", + "section": "concepts" + }, { "port": "kotlin", "symbol": "io.github.libtmux.kotlin.Server", @@ -3794,6 +3962,13 @@ "title": "libtmux-kotlin", "section": "guides" }, + { + "port": "kotlin", + "symbol": "io.github.libtmux.kotlin.Session", + "page": "/kotlin/latest/concepts/server-session-window-pane/", + "title": "Server, session, window, pane", + "section": "concepts" + }, { "port": "kotlin", "symbol": "io.github.libtmux.kotlin.Session", @@ -3871,6 +4046,13 @@ "title": "Kotlin", "section": "guides" }, + { + "port": "kotlin", + "symbol": "io.github.libtmux.kotlin.Window", + "page": "/kotlin/latest/concepts/server-session-window-pane/", + "title": "Server, session, window, pane", + "section": "concepts" + }, { "port": "kotlin", "symbol": "io.github.libtmux.kotlin.Window", @@ -3892,6 +4074,13 @@ "title": "Kotlin", "section": "guides" }, + { + "port": "kotlin", + "symbol": "io.github.libtmux.kotlin.withControl", + "page": "/kotlin/latest/concepts/transports/", + "title": "Commands and control mode", + "section": "concepts" + }, { "port": "kotlin", "symbol": "io.github.libtmux.kotlin.withControl", @@ -3899,6 +4088,13 @@ "title": "libtmux-kotlin", "section": "guides" }, + { + "port": "kotlin", + "symbol": "io.github.libtmux.kotlin.withServer", + "page": "/kotlin/latest/concepts/transports/", + "title": "Commands and control mode", + "section": "concepts" + }, { "port": "kotlin", "symbol": "io.github.libtmux.kotlin.withServer", @@ -7266,6 +7462,13 @@ "title": "Ownership", "section": "guides" }, + { + "port": "scala", + "symbol": "io.github.libtmux.scaladsl.cats.Server.resource", + "page": "/scala/latest/concepts/transports/", + "title": "Commands and control mode", + "section": "concepts" + }, { "port": "scala", "symbol": "io.github.libtmux.scaladsl.cats.Server.resource", @@ -7308,6 +7511,27 @@ "title": "Execution", "section": "guides" }, + { + "port": "scala", + "symbol": "io.github.libtmux.scaladsl.query.CardinalityError.MultipleMatches", + "page": "/scala/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, + { + "port": "scala", + "symbol": "io.github.libtmux.scaladsl.query.CardinalityError.NoMatch", + "page": "/scala/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, + { + "port": "scala", + "symbol": "io.github.libtmux.scaladsl.query.Expr", + "page": "/scala/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, { "port": "scala", "symbol": "io.github.libtmux.scaladsl.query.Expr.not", @@ -7329,6 +7553,13 @@ "title": "Queries", "section": "guides" }, + { + "port": "scala", + "symbol": "io.github.libtmux.scaladsl.query.Fields.ToManyRef.all", + "page": "/scala/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, { "port": "scala", "symbol": "io.github.libtmux.scaladsl.query.Fields.ToManyRef.all", @@ -7336,6 +7567,13 @@ "title": "Queries", "section": "guides" }, + { + "port": "scala", + "symbol": "io.github.libtmux.scaladsl.query.Fields.ToManyRef.any", + "page": "/scala/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, { "port": "scala", "symbol": "io.github.libtmux.scaladsl.query.Fields.ToManyRef.any", @@ -7343,6 +7581,13 @@ "title": "Queries", "section": "guides" }, + { + "port": "scala", + "symbol": "io.github.libtmux.scaladsl.query.Fields.ToManyRef.none", + "page": "/scala/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, { "port": "scala", "symbol": "io.github.libtmux.scaladsl.query.Fields.ToManyRef.none", @@ -7469,6 +7714,13 @@ "title": "Streaming", "section": "guides" }, + { + "port": "scala", + "symbol": "io.github.libtmux.scaladsl.Vector.matching", + "page": "/scala/latest/concepts/queries/", + "title": "Filtering and queries", + "section": "concepts" + }, { "port": "scala", "symbol": "io.github.libtmux.scaladsl.Vector.matching", @@ -9655,6 +9907,13 @@ "line": 150, "why": "not defined in the stated port" }, + { + "port": "kotlin", + "text": "CardinalityException.MultipleMatches", + "page": "/kotlin/latest/concepts/queries/", + "line": 145, + "why": "not defined in the stated port" + }, { "port": "kotlin", "text": "WatchPaneOutput", diff --git a/site/src/lib/port-documentation.ts b/site/src/lib/port-documentation.ts index fe6c5920..4bf6b526 100644 --- a/site/src/lib/port-documentation.ts +++ b/site/src/lib/port-documentation.ts @@ -63,7 +63,7 @@ const PORT_DOCUMENTATION: Readonly> = { domains: [libraryDomain('Kotlin coroutine handles, builders and flows over the Java/JVM library.')], sourceGuides: [ guide('libtmux-kotlin/README.md', 'guides/getting-started', 'core'), - guide('docs/guide/kotlin.md', 'guides/coroutines', 'core', { aliases: ['guides/source/coroutines', 'concepts/transports'] }), + guide('docs/guide/kotlin.md', 'guides/coroutines', 'core', { aliases: ['guides/source/coroutines'] }), ], }, scala: { @@ -72,7 +72,7 @@ const PORT_DOCUMENTATION: Readonly> = { guide('libtmux-scala/README.md', 'guides/overview', 'core'), ...['getting-started', 'query', 'ownership', 'execution', 'streaming', 'compatibility'].map((name) => guide(`docs/guide/scala/${name}.md`, `guides/${name}`, 'core', { - aliases: [`guides/source/${name}`, ...(name === 'execution' ? ['concepts/transports'] : [])], + aliases: [`guides/source/${name}`], })), ], }, @@ -82,7 +82,7 @@ const PORT_DOCUMENTATION: Readonly> = { guide('src/LibTmux.FSharp/README.md', 'guides/quickstart', 'core'), ...['getting-started', 'queries', 'streams', 'interop', 'modes', 'supported-query-fields'].map((name) => guide(`docs/fsharp/${name}.md`, `guides/${name}`, 'core', { - aliases: [`guides/source/${name}`, ...(name === 'modes' ? ['concepts/transports'] : [])], + aliases: [`guides/source/${name}`], })), guide('docs/fsharp/api.md', 'guides/api-overview', 'core', { aliases: [] }), ], diff --git a/site/test/complete-examples.test.ts b/site/test/complete-examples.test.ts index f00fb297..4d7e9511 100644 --- a/site/test/complete-examples.test.ts +++ b/site/test/complete-examples.test.ts @@ -7,6 +7,7 @@ import receipt from './fixtures/capture-examples.json' import attach from './fixtures/attach-examples.json' import products from './fixtures/product-examples.json' import queries from './fixtures/query-examples.json' +import concepts from './fixtures/concept-examples.json' import { remarkPortCode, resolvePortCode } from '../src/plugins/remark-port-code.mjs' import { rehypeCodeTabs } from '../src/plugins/rehype-code-tabs.mjs' import { docsEntryAvailable, pagePortLinks } from '../src/lib/page-port-links' @@ -18,11 +19,26 @@ const bodyOf = (page: string) => parsePage(page).content const fences = (markdown: string) => [...markdown.matchAll(/^```(\S+)([^\n]*)\n([\s\S]*?)^```/gm)] .map((match) => ({ language: match[1], title: /title="([^"]+)"/.exec(match[2])?.[1], code: match[3] })) const sha256 = (code: string) => createHash('sha256').update(code).digest('hex') -const examples = [...receipt.examples, ...attach.examples, ...products.examples, ...queries.examples] +const examples = [...receipt.examples, ...attach.examples, ...products.examples, ...queries.examples, ...concepts.examples] afterEach(() => vi.unstubAllEnvs()) describe('verified complete programs', () => { + it.each(['kotlin', 'scala', 'fsharp'])('gives %s one owned route for every staple concept', (port) => { + const paths = ['index', 'server-session-window-pane', 'queries', 'transports', 'workspaces'] + const docs = paths.flatMap((path) => { + const route = path === 'index' ? 'concepts' : `concepts/${path}` + return [ + { id: route, data: parsePage(`concepts/${path}`).frontmatter }, + { id: `ports/${port}/concepts/${path}`, data: parsePage(`ports/${port}/concepts/${path}`).frontmatter }, + ] + }) + const available = docs.filter((entry) => docsEntryAvailable(entry, port)) + expect(available).toHaveLength(paths.length) + expect(available.every((entry) => entry.data.port === port)).toBe(true) + expect(new Set(available.map((entry) => docsRoutePath(entry, port))).size).toBe(paths.length) + }) + // The receipt records separate native runs. This gate protects their exact // bytes through the renderers; changing a hash alone is not a native test. it.each(examples)('keeps the executed $page program and project files intact', (example) => { diff --git a/site/test/fixtures/concept-examples.json b/site/test/fixtures/concept-examples.json new file mode 100644 index 00000000..0c443099 --- /dev/null +++ b/site/test/fixtures/concept-examples.json @@ -0,0 +1,626 @@ +{ + "examples": [ + { + "port": "kotlin", + "page": "ports/kotlin/concepts/server-session-window-pane", + "sourceRevision": "be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15", + "runtime": "Temurin JDK 25.0.3", + "tmuxVersions": [ + "3.2a", + "3.7c" + ], + "files": [ + { + "name": "settings.gradle.kts", + "sha256": "b00ae8397b15423ec37743ffe34966e3123fa2a21996d36457953dff131d60bc" + }, + { + "name": "build.gradle.kts", + "sha256": "7c086e8a937a8219dd20df5b5380048d993b0c0ed9aea65e13a017428e3b50b7" + }, + { + "name": "gradle.properties", + "sha256": "aa2f75584718e340a2dab7af0d04d010cd8d0b83fc6b16f96a141cd3244821c1" + }, + { + "name": "run.sh", + "sha256": "ea8339a28da6dd07d7c71bc1e264b246b0ea4561865e11d48b3642448a251bee" + }, + { + "name": "Hierarchy.kt", + "sha256": "5443942fb2c68dfd767bb41953590d29a9da3b2500a4d1269d3fd9e47c2ca528" + }, + { + "name": "Refresh.kt", + "sha256": "1906afc0b0dee23b964f92560b834a4a308f7dc0545e269d2d66b21d6f942b9a" + } + ], + "shellRecipe": [ + "git clone https://github.com/libtmux/libtmux-java libtmux-source &&\n git -C libtmux-source checkout be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=Hierarchy --console=plain --max-workers=2", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=Refresh --console=plain --max-workers=2" + ], + "expectedOutputs": [ + [], + [ + "work-one: editor", + "work-two: logs", + "2 sessions, 2 windows, 2 panes" + ], + [ + "editor -> renamed" + ] + ], + "verificationScope": "Each displayed program runs independently with its displayed project, launcher and pinned library. Native checks cover assertions, stdout and cleanup." + }, + { + "port": "kotlin", + "page": "ports/kotlin/concepts/queries", + "sourceRevision": "be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15", + "runtime": "Temurin JDK 25.0.3", + "tmuxVersions": [ + "3.2a", + "3.7c" + ], + "files": [ + { + "name": "settings.gradle.kts", + "sha256": "b00ae8397b15423ec37743ffe34966e3123fa2a21996d36457953dff131d60bc" + }, + { + "name": "build.gradle.kts", + "sha256": "7c086e8a937a8219dd20df5b5380048d993b0c0ed9aea65e13a017428e3b50b7" + }, + { + "name": "gradle.properties", + "sha256": "aa2f75584718e340a2dab7af0d04d010cd8d0b83fc6b16f96a141cd3244821c1" + }, + { + "name": "run.sh", + "sha256": "ea8339a28da6dd07d7c71bc1e264b246b0ea4561865e11d48b3642448a251bee" + }, + { + "name": "Local.kt", + "sha256": "1b24c8458c02b970db86394e03e45917035548082ecdf5c1576cd6fd54671802" + }, + { + "name": "Cardinality.kt", + "sha256": "4e544e598603d444598d92cb6405b7e11ef8ea38e20b6c11c0d5f4d16e493108" + }, + { + "name": "Relations.kt", + "sha256": "4255357d8f85f8c61a4424bddd384836aa13ecbb5bc2f5ee066f2d3184a8f14a" + } + ], + "shellRecipe": [ + "git clone https://github.com/libtmux/libtmux-java libtmux-source &&\n git -C libtmux-source checkout be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=Local --console=plain --max-workers=2", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=Cardinality --console=plain --max-workers=2", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=Relations --console=plain --max-workers=2" + ], + "expectedOutputs": [ + [], + [ + "work-one, work-two" + ], + [ + "work-one: selected", + "missing: absent", + "work-: ambiguous" + ], + [ + "editor: work-one", + "no editor: work-two" + ] + ], + "verificationScope": "Each displayed program runs independently with its displayed project, launcher and pinned library. Native checks cover assertions, stdout and cleanup." + }, + { + "port": "kotlin", + "page": "ports/kotlin/concepts/transports", + "sourceRevision": "be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15", + "runtime": "Temurin JDK 25.0.3", + "tmuxVersions": [ + "3.2a", + "3.7c" + ], + "files": [ + { + "name": "settings.gradle.kts", + "sha256": "b00ae8397b15423ec37743ffe34966e3123fa2a21996d36457953dff131d60bc" + }, + { + "name": "build.gradle.kts", + "sha256": "7c086e8a937a8219dd20df5b5380048d993b0c0ed9aea65e13a017428e3b50b7" + }, + { + "name": "gradle.properties", + "sha256": "aa2f75584718e340a2dab7af0d04d010cd8d0b83fc6b16f96a141cd3244821c1" + }, + { + "name": "run.sh", + "sha256": "ea8339a28da6dd07d7c71bc1e264b246b0ea4561865e11d48b3642448a251bee" + }, + { + "name": "Local.kt", + "sha256": "1b24c8458c02b970db86394e03e45917035548082ecdf5c1576cd6fd54671802" + }, + { + "name": "Control.kt", + "sha256": "5617b3a3967ccf5911d9b8adaa4a7790b1ee2dbdc4b2af7de316692945675bea" + } + ], + "shellRecipe": [ + "git clone https://github.com/libtmux/libtmux-java libtmux-source &&\n git -C libtmux-source checkout be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=Local --console=plain --max-workers=2", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=Control --console=plain --max-workers=2" + ], + "expectedOutputs": [ + [], + [ + "work-one, work-two" + ], + [ + "work-one, work-two", + "control client closed; server still running" + ] + ], + "verificationScope": "Each displayed program runs independently with its displayed project, launcher and pinned library. Native checks cover assertions, stdout and cleanup." + }, + { + "port": "kotlin", + "page": "ports/kotlin/concepts/workspaces", + "sourceRevision": "be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15", + "runtime": "Temurin JDK 25.0.3", + "tmuxVersions": [ + "3.2a", + "3.7c" + ], + "files": [ + { + "name": "settings.gradle.kts", + "sha256": "b00ae8397b15423ec37743ffe34966e3123fa2a21996d36457953dff131d60bc" + }, + { + "name": "build.gradle.kts", + "sha256": "7c086e8a937a8219dd20df5b5380048d993b0c0ed9aea65e13a017428e3b50b7" + }, + { + "name": "gradle.properties", + "sha256": "aa2f75584718e340a2dab7af0d04d010cd8d0b83fc6b16f96a141cd3244821c1" + }, + { + "name": "run.sh", + "sha256": "ea8339a28da6dd07d7c71bc1e264b246b0ea4561865e11d48b3642448a251bee" + }, + { + "name": "Layout.kt", + "sha256": "2ce63e4cbd402cd9c693238d5aac1dd4e1ef69ce765b319bf4dc25ad06987e29" + }, + { + "name": "ReuseLayout.kt", + "sha256": "3c927e91817bb570a90d7f736d3eea9437bd97a70f4d67dee4a64d5ff4c9aa57" + } + ], + "shellRecipe": [ + "git clone https://github.com/libtmux/libtmux-java libtmux-source &&\n git -C libtmux-source checkout be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=Layout --console=plain --max-workers=2", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=ReuseLayout --console=plain --max-workers=2" + ], + "expectedOutputs": [ + [], + [ + "tools: 2 panes" + ], + [ + "one tools window after two calls" + ] + ], + "verificationScope": "Each displayed program runs independently with its displayed project, launcher and pinned library. Native checks cover assertions, stdout and cleanup." + }, + { + "port": "scala", + "page": "ports/scala/concepts/server-session-window-pane", + "sourceRevision": "be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15", + "runtime": "Temurin JDK 25.0.3", + "tmuxVersions": [ + "3.2a", + "3.7c" + ], + "files": [ + { + "name": "settings.gradle.kts", + "sha256": "84f6f8c1760984676ed15761a8838fba725ee18fee25a28b8cc3f94ad3e24b99" + }, + { + "name": "build.gradle.kts", + "sha256": "c94c54df066047a1040b70e4f69a303426174aac83683b8cd1c274d6213511e9" + }, + { + "name": "gradle.properties", + "sha256": "aa2f75584718e340a2dab7af0d04d010cd8d0b83fc6b16f96a141cd3244821c1" + }, + { + "name": "run.sh", + "sha256": "ea8339a28da6dd07d7c71bc1e264b246b0ea4561865e11d48b3642448a251bee" + }, + { + "name": "Hierarchy.scala", + "sha256": "bf83e9625aed62a5585f403de180f83f163de4292ae5f53d333ba9c1dd06a91f" + }, + { + "name": "Refresh.scala", + "sha256": "7ed6ee12473851261ef6117b57841fcc420bf22635e306b8c054795af15a75a8" + } + ], + "shellRecipe": [ + "git clone https://github.com/libtmux/libtmux-java libtmux-source &&\n git -C libtmux-source checkout be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=Hierarchy --console=plain --max-workers=2", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=Refresh --console=plain --max-workers=2" + ], + "expectedOutputs": [ + [], + [ + "work-one: editor", + "work-two: logs", + "2 sessions, 2 windows, 2 panes" + ], + [ + "editor -> renamed" + ] + ], + "verificationScope": "Each displayed program runs independently with its displayed project, launcher and pinned library. Native checks cover assertions, stdout and cleanup." + }, + { + "port": "scala", + "page": "ports/scala/concepts/queries", + "sourceRevision": "be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15", + "runtime": "Temurin JDK 25.0.3", + "tmuxVersions": [ + "3.2a", + "3.7c" + ], + "files": [ + { + "name": "settings.gradle.kts", + "sha256": "84f6f8c1760984676ed15761a8838fba725ee18fee25a28b8cc3f94ad3e24b99" + }, + { + "name": "build.gradle.kts", + "sha256": "c94c54df066047a1040b70e4f69a303426174aac83683b8cd1c274d6213511e9" + }, + { + "name": "gradle.properties", + "sha256": "aa2f75584718e340a2dab7af0d04d010cd8d0b83fc6b16f96a141cd3244821c1" + }, + { + "name": "run.sh", + "sha256": "ea8339a28da6dd07d7c71bc1e264b246b0ea4561865e11d48b3642448a251bee" + }, + { + "name": "Local.scala", + "sha256": "7d714f11710c2a1cf613d6e9a2c2bd43547f2b6a183c758178298a1fe8f90599" + }, + { + "name": "Cardinality.scala", + "sha256": "2abf4f046a9253958c1d6a25ceb0b41f2a0bbcd70b62f42242a736e5b603c60c" + }, + { + "name": "Relations.scala", + "sha256": "a197b7e27f24d7c5717cd5cd28f215977471e5791c9f4470aefcfc4d2f1048d2" + } + ], + "shellRecipe": [ + "git clone https://github.com/libtmux/libtmux-java libtmux-source &&\n git -C libtmux-source checkout be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=Local --console=plain --max-workers=2", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=Cardinality --console=plain --max-workers=2", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=Relations --console=plain --max-workers=2" + ], + "expectedOutputs": [ + [], + [ + "work-one, work-two" + ], + [ + "work-one: selected", + "missing: absent", + "work-: ambiguous" + ], + [ + "editor: work-one", + "no editor: work-two" + ] + ], + "verificationScope": "Each displayed program runs independently with its displayed project, launcher and pinned library. Native checks cover assertions, stdout and cleanup." + }, + { + "port": "scala", + "page": "ports/scala/concepts/transports", + "sourceRevision": "be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15", + "runtime": "Temurin JDK 25.0.3", + "tmuxVersions": [ + "3.2a", + "3.7c" + ], + "files": [ + { + "name": "settings.gradle.kts", + "sha256": "84f6f8c1760984676ed15761a8838fba725ee18fee25a28b8cc3f94ad3e24b99" + }, + { + "name": "build.gradle.kts", + "sha256": "c94c54df066047a1040b70e4f69a303426174aac83683b8cd1c274d6213511e9" + }, + { + "name": "gradle.properties", + "sha256": "aa2f75584718e340a2dab7af0d04d010cd8d0b83fc6b16f96a141cd3244821c1" + }, + { + "name": "run.sh", + "sha256": "ea8339a28da6dd07d7c71bc1e264b246b0ea4561865e11d48b3642448a251bee" + }, + { + "name": "Local.scala", + "sha256": "7d714f11710c2a1cf613d6e9a2c2bd43547f2b6a183c758178298a1fe8f90599" + }, + { + "name": "Control.scala", + "sha256": "a541715d9c976c57716a679524d060579c13cf276890f0b74732ec326b680f62" + } + ], + "shellRecipe": [ + "git clone https://github.com/libtmux/libtmux-java libtmux-source &&\n git -C libtmux-source checkout be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=Local --console=plain --max-workers=2", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=Control --console=plain --max-workers=2" + ], + "expectedOutputs": [ + [], + [ + "work-one, work-two" + ], + [ + "work-one, work-two", + "control client closed; server still running" + ] + ], + "verificationScope": "Each displayed program runs independently with its displayed project, launcher and pinned library. Native checks cover assertions, stdout and cleanup." + }, + { + "port": "scala", + "page": "ports/scala/concepts/workspaces", + "sourceRevision": "be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15", + "runtime": "Temurin JDK 25.0.3", + "tmuxVersions": [ + "3.2a", + "3.7c" + ], + "files": [ + { + "name": "settings.gradle.kts", + "sha256": "84f6f8c1760984676ed15761a8838fba725ee18fee25a28b8cc3f94ad3e24b99" + }, + { + "name": "build.gradle.kts", + "sha256": "c94c54df066047a1040b70e4f69a303426174aac83683b8cd1c274d6213511e9" + }, + { + "name": "gradle.properties", + "sha256": "aa2f75584718e340a2dab7af0d04d010cd8d0b83fc6b16f96a141cd3244821c1" + }, + { + "name": "run.sh", + "sha256": "ea8339a28da6dd07d7c71bc1e264b246b0ea4561865e11d48b3642448a251bee" + }, + { + "name": "Layout.scala", + "sha256": "bc3501864a170b3f208064ef720fa104daf8e1e264101ebe8ad7e88708ffd451" + }, + { + "name": "ReuseLayout.scala", + "sha256": "7b37add5e44fbb60fee277c95b6728c815826dac4869b749c8a737cce530f204" + } + ], + "shellRecipe": [ + "git clone https://github.com/libtmux/libtmux-java libtmux-source &&\n git -C libtmux-source checkout be1d62fbaa1aa634c687e1c50bceeb09ef0b8a15", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=Layout --console=plain --max-workers=2", + "sh run.sh ./libtmux-source/gradlew --project-dir . run \\\n -Pexample=ReuseLayout --console=plain --max-workers=2" + ], + "expectedOutputs": [ + [], + [ + "tools: 2 panes" + ], + [ + "one tools window after two calls" + ] + ], + "verificationScope": "Each displayed program runs independently with its displayed project, launcher and pinned library. Native checks cover assertions, stdout and cleanup." + }, + { + "port": "fsharp", + "page": "ports/fsharp/concepts/server-session-window-pane", + "sourceRevision": "2d99ead5aba8d968e85dcac8c9a9a518452e6ba6", + "runtime": ".NET SDK 10.0.302", + "tmuxVersions": [ + "3.2a", + "3.7c" + ], + "files": [ + { + "name": "Query.fsproj", + "sha256": "cede2d70f84c645ef1e609e648d1794abdd8d96fb8f0f957a274d11ea2aec212" + }, + { + "name": "run.sh", + "sha256": "dacfb3ef36d757bb66b1c97bacc575348e2466866dee63d53b95895932f8e40b" + }, + { + "name": "Hierarchy.fs", + "sha256": "1b2b94d47c06e1f2ce86e46b3d55eb0576414e2f2e59d8d0328e94e206506046" + }, + { + "name": "Refresh.fs", + "sha256": "55e801b201571844a295b9b6f002632d0b34c45212773e998d251ac4fc4eaedf" + } + ], + "shellRecipe": [ + "git clone https://github.com/libtmux/libtmux-dotnet libtmux-source &&\n git -C libtmux-source checkout 2d99ead5aba8d968e85dcac8c9a9a518452e6ba6", + "dotnet build Query.fsproj --maxcpucount:1 -p:Example=Hierarchy \\\n -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath=\"$PWD/.packages\" &&\n sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Hierarchy", + "dotnet build Query.fsproj --maxcpucount:1 -p:Example=Refresh \\\n -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath=\"$PWD/.packages\" &&\n sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Refresh" + ], + "expectedOutputs": [ + [], + [ + "work-one: editor", + "work-two: logs", + "2 sessions, 2 windows, 2 panes" + ], + [ + "editor -> renamed" + ] + ], + "verificationScope": "Each displayed program runs independently with its displayed project, launcher and pinned library. Native checks cover assertions, stdout and cleanup." + }, + { + "port": "fsharp", + "page": "ports/fsharp/concepts/queries", + "sourceRevision": "2d99ead5aba8d968e85dcac8c9a9a518452e6ba6", + "runtime": ".NET SDK 10.0.302", + "tmuxVersions": [ + "3.2a", + "3.7c" + ], + "files": [ + { + "name": "Query.fsproj", + "sha256": "cede2d70f84c645ef1e609e648d1794abdd8d96fb8f0f957a274d11ea2aec212" + }, + { + "name": "run.sh", + "sha256": "dacfb3ef36d757bb66b1c97bacc575348e2466866dee63d53b95895932f8e40b" + }, + { + "name": "Local.fs", + "sha256": "367291c85b89affaddbd6104932c4ab5e14b49fc01e339f2eb82ff67f0adbd15" + }, + { + "name": "Cardinality.fs", + "sha256": "705ca5f30a953763a11d3bc206ffe647616d8537e4a15373f1da48c1bf1a88d9" + }, + { + "name": "Relations.fs", + "sha256": "19d95e24c7a274257ba65e15fae9b773e0418e5266ef0436f5ba78c16e7d8040" + } + ], + "shellRecipe": [ + "git clone https://github.com/libtmux/libtmux-dotnet libtmux-source &&\n git -C libtmux-source checkout 2d99ead5aba8d968e85dcac8c9a9a518452e6ba6", + "dotnet build Query.fsproj --maxcpucount:1 -p:Example=Local \\\n -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath=\"$PWD/.packages\" &&\n sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Local", + "dotnet build Query.fsproj --maxcpucount:1 -p:Example=Cardinality \\\n -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath=\"$PWD/.packages\" &&\n sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Cardinality", + "dotnet build Query.fsproj --maxcpucount:1 -p:Example=Relations \\\n -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath=\"$PWD/.packages\" &&\n sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Relations" + ], + "expectedOutputs": [ + [], + [ + "work-one, work-two" + ], + [ + "work-one: selected", + "missing: absent", + "work-: ambiguous" + ], + [ + "editor: work-one", + "no editor: work-two" + ] + ], + "verificationScope": "Each displayed program runs independently with its displayed project, launcher and pinned library. Native checks cover assertions, stdout and cleanup." + }, + { + "port": "fsharp", + "page": "ports/fsharp/concepts/transports", + "sourceRevision": "2d99ead5aba8d968e85dcac8c9a9a518452e6ba6", + "runtime": ".NET SDK 10.0.302", + "tmuxVersions": [ + "3.2a", + "3.7c" + ], + "files": [ + { + "name": "Query.fsproj", + "sha256": "cede2d70f84c645ef1e609e648d1794abdd8d96fb8f0f957a274d11ea2aec212" + }, + { + "name": "run.sh", + "sha256": "dacfb3ef36d757bb66b1c97bacc575348e2466866dee63d53b95895932f8e40b" + }, + { + "name": "Local.fs", + "sha256": "367291c85b89affaddbd6104932c4ab5e14b49fc01e339f2eb82ff67f0adbd15" + }, + { + "name": "Control.fs", + "sha256": "f51c2714f86a4951f1717ec0f6fd565e551def94e70013dec597152f44b52031" + } + ], + "shellRecipe": [ + "git clone https://github.com/libtmux/libtmux-dotnet libtmux-source &&\n git -C libtmux-source checkout 2d99ead5aba8d968e85dcac8c9a9a518452e6ba6", + "dotnet build Query.fsproj --maxcpucount:1 -p:Example=Local \\\n -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath=\"$PWD/.packages\" &&\n sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Local", + "dotnet build Query.fsproj --maxcpucount:1 -p:Example=Control \\\n -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath=\"$PWD/.packages\" &&\n sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Control" + ], + "expectedOutputs": [ + [], + [ + "work-one, work-two" + ], + [ + "work-one, work-two", + "control client closed; server still running" + ] + ], + "verificationScope": "Each displayed program runs independently with its displayed project, launcher and pinned library. Native checks cover assertions, stdout and cleanup." + }, + { + "port": "fsharp", + "page": "ports/fsharp/concepts/workspaces", + "sourceRevision": "2d99ead5aba8d968e85dcac8c9a9a518452e6ba6", + "runtime": ".NET SDK 10.0.302", + "tmuxVersions": [ + "3.2a", + "3.7c" + ], + "files": [ + { + "name": "Query.fsproj", + "sha256": "cede2d70f84c645ef1e609e648d1794abdd8d96fb8f0f957a274d11ea2aec212" + }, + { + "name": "run.sh", + "sha256": "dacfb3ef36d757bb66b1c97bacc575348e2466866dee63d53b95895932f8e40b" + }, + { + "name": "Layout.fs", + "sha256": "bb000c5be099288ee305204d32533c6296c498225654968698db282a4859ddc7" + }, + { + "name": "ReuseLayout.fs", + "sha256": "1b81e1fdf0b1a8346c9c5cf10add566798dc07c5418fed049d0a291c03d7b313" + } + ], + "shellRecipe": [ + "git clone https://github.com/libtmux/libtmux-dotnet libtmux-source &&\n git -C libtmux-source checkout 2d99ead5aba8d968e85dcac8c9a9a518452e6ba6", + "dotnet build Query.fsproj --maxcpucount:1 -p:Example=Layout \\\n -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath=\"$PWD/.packages\" &&\n sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=Layout", + "dotnet build Query.fsproj --maxcpucount:1 -p:Example=ReuseLayout \\\n -p:DisableImplicitLibraryPacksFolder=true -p:RestorePackagesPath=\"$PWD/.packages\" &&\n sh run.sh dotnet run --project Query.fsproj --no-build -p:Example=ReuseLayout" + ], + "expectedOutputs": [ + [], + [ + "tools: 2 panes" + ], + [ + "one tools window after two calls" + ] + ], + "verificationScope": "Each displayed program runs independently with its displayed project, launcher and pinned library. Native checks cover assertions, stdout and cleanup." + } + ] +} diff --git a/site/test/product-docs.test.ts b/site/test/product-docs.test.ts index 2f0ec413..e7de4ff9 100644 --- a/site/test/product-docs.test.ts +++ b/site/test/product-docs.test.ts @@ -299,9 +299,8 @@ describe.skipIf(!SITE_BUILT)('assembled MCP and Workspace Manager docs', () => { expect(checked, 'overviews with an install picker').toBeGreaterThan(0) }) - it('distinguishes unfinished products and groups workspace implementation docs under Internals', async () => { - for (const page of pages()) await inspect(page.path, (document) => { - const port = PORTS.find((entry) => entry.slug === page.port)! + it.each(PORTS.filter((port) => !port.parentLibrary))('$name distinguishes unfinished products and groups workspace internals', async (port) => { + for (const page of pages().filter((entry) => entry.port === port.slug)) await inspect(page.path, (document) => { if (productInDevelopment(port, page.product)) developmentStatus(document, page.path) const navigation = document.querySelectorAll('nav[aria-label="Documentation"]') expect(navigation.length, `${page.path} documentation navigation`).toBeGreaterThan(0)