terminux 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- terminux-0.1.0/.gitignore +10 -0
- terminux-0.1.0/.python-version +1 -0
- terminux-0.1.0/Makefile +34 -0
- terminux-0.1.0/PKG-INFO +11 -0
- terminux-0.1.0/README.md +0 -0
- terminux-0.1.0/pyproject.toml +78 -0
- terminux-0.1.0/src/terminux/__init__.py +5 -0
- terminux-0.1.0/src/terminux/__main__.py +8 -0
- terminux-0.1.0/src/terminux/app.py +123 -0
- terminux-0.1.0/src/terminux/core/__init__.py +3 -0
- terminux-0.1.0/src/terminux/core/model.py +291 -0
- terminux-0.1.0/src/terminux/core/persistence.py +58 -0
- terminux-0.1.0/src/terminux/core/pty_backend.py +137 -0
- terminux-0.1.0/src/terminux/core/shellprobe.py +33 -0
- terminux-0.1.0/src/terminux/core/terminal.py +144 -0
- terminux-0.1.0/src/terminux/server/__init__.py +3 -0
- terminux-0.1.0/src/terminux/server/asgi.py +324 -0
- terminux-0.1.0/src/terminux/server/auth.py +16 -0
- terminux-0.1.0/src/terminux/web/index.html +134 -0
- terminux-0.1.0/src/terminux/web/main.js +493 -0
- terminux-0.1.0/src/terminux/web/vendor/addon-fit.js +2 -0
- terminux-0.1.0/src/terminux/web/vendor/xterm.css +218 -0
- terminux-0.1.0/src/terminux/web/vendor/xterm.js +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
terminux-0.1.0/Makefile
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
.PHONY: all test build format check lint clean
|
|
2
|
+
|
|
3
|
+
all: test lint
|
|
4
|
+
|
|
5
|
+
check: lint
|
|
6
|
+
|
|
7
|
+
lint:
|
|
8
|
+
uv run --active ruff check
|
|
9
|
+
uv run --active ruff format --check
|
|
10
|
+
uv run --active ty check src
|
|
11
|
+
uv run --active pyrefly check src
|
|
12
|
+
uv run --active mypy src
|
|
13
|
+
|
|
14
|
+
format:
|
|
15
|
+
uv run --active ruff format src tests
|
|
16
|
+
uv run --active ruff check src tests --fix
|
|
17
|
+
uv run --active ruff format src tests
|
|
18
|
+
|
|
19
|
+
test:
|
|
20
|
+
uv run pytest
|
|
21
|
+
|
|
22
|
+
test-cov:
|
|
23
|
+
uv run pytest --cov=terminux --cov-report=html --cov-report=term tests
|
|
24
|
+
|
|
25
|
+
clean:
|
|
26
|
+
rm -rf .pytest_cache .ruff_cache dist build __pycache__ .mypy_cache \
|
|
27
|
+
.coverage htmlcov .coverage.* *.egg-info
|
|
28
|
+
adt clean
|
|
29
|
+
|
|
30
|
+
build: clean
|
|
31
|
+
uv build
|
|
32
|
+
|
|
33
|
+
publish: build
|
|
34
|
+
uv publish
|
terminux-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: terminux
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A cross-platform desktop terminal with workspaces and tabs
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: platformdirs>=4.0
|
|
7
|
+
Requires-Dist: ptyprocess>=0.7
|
|
8
|
+
Requires-Dist: pywebview>=5.0
|
|
9
|
+
Requires-Dist: starlette>=0.40
|
|
10
|
+
Requires-Dist: uvicorn>=0.30
|
|
11
|
+
Requires-Dist: websockets>=13.0
|
terminux-0.1.0/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "terminux"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A cross-platform desktop terminal with workspaces and tabs"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pywebview>=5.0",
|
|
9
|
+
"starlette>=0.40",
|
|
10
|
+
"uvicorn>=0.30",
|
|
11
|
+
"ptyprocess>=0.7",
|
|
12
|
+
"platformdirs>=4.0",
|
|
13
|
+
"websockets>=13.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
terminux = "terminux.app:main"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/terminux"]
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"mypy>=2.1.0",
|
|
29
|
+
"nox>=2026.4.10",
|
|
30
|
+
"pre-commit>=4.6.0",
|
|
31
|
+
"pyrefly>=1.0.0",
|
|
32
|
+
"pytest>=9.0.3",
|
|
33
|
+
"pytest-cov>=7.1.0",
|
|
34
|
+
"ruff>=0.15.13",
|
|
35
|
+
"ty>=0.0.37",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
markers = [
|
|
41
|
+
"unit: fast isolated unit tests",
|
|
42
|
+
"integration: component-interaction tests",
|
|
43
|
+
"e2e: end-to-end workflow tests",
|
|
44
|
+
]
|
|
45
|
+
filterwarnings = [
|
|
46
|
+
# Inherent to spawning a PTY from our multi-threaded process (ptyprocess
|
|
47
|
+
# uses pty.fork, then execs immediately); safe here, not actionable.
|
|
48
|
+
"ignore:This process .* is multi-threaded, use of forkpty.*:DeprecationWarning",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[tool.mypy]
|
|
52
|
+
strict = true
|
|
53
|
+
|
|
54
|
+
[[tool.mypy.overrides]]
|
|
55
|
+
module = ["ptyprocess", "webview", "webview.*"]
|
|
56
|
+
ignore_missing_imports = true
|
|
57
|
+
|
|
58
|
+
[tool.coverage.run]
|
|
59
|
+
branch = true
|
|
60
|
+
source = ["terminux"]
|
|
61
|
+
|
|
62
|
+
[tool.coverage.report]
|
|
63
|
+
show_missing = true
|
|
64
|
+
exclude_lines = [
|
|
65
|
+
"pragma: no cover",
|
|
66
|
+
"if TYPE_CHECKING:",
|
|
67
|
+
"raise NotImplementedError",
|
|
68
|
+
"if __name__ == .__main__.:",
|
|
69
|
+
"\\.\\.\\.",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
[tool.pyrefly]
|
|
73
|
+
preset = "legacy"
|
|
74
|
+
ignore-missing-imports = [
|
|
75
|
+
"ptyprocess",
|
|
76
|
+
"webview",
|
|
77
|
+
"webview.*",
|
|
78
|
+
]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Entrypoint: run the loopback server, then host it in a pywebview window.
|
|
2
|
+
|
|
3
|
+
``--no-window`` runs the server headless (browse to the printed URL) — used
|
|
4
|
+
for development and e2e tests, and a preview of the future "web mode".
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import contextlib
|
|
11
|
+
import logging
|
|
12
|
+
import socket
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from typing import cast
|
|
16
|
+
|
|
17
|
+
import uvicorn
|
|
18
|
+
|
|
19
|
+
from terminux.server.asgi import AppController, build_app
|
|
20
|
+
from terminux.server.auth import SESSION_TOKEN
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
HOST = "127.0.0.1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _free_port() -> int:
|
|
27
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
28
|
+
s.bind((HOST, 0))
|
|
29
|
+
return int(s.getsockname()[1])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _serve(server: uvicorn.Server) -> None:
|
|
33
|
+
server.run()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main() -> None:
|
|
37
|
+
logging.basicConfig(
|
|
38
|
+
level=logging.INFO, format="%(levelname)s %(name)s: %(message)s"
|
|
39
|
+
)
|
|
40
|
+
parser = argparse.ArgumentParser(prog="terminux")
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--no-window",
|
|
43
|
+
action="store_true",
|
|
44
|
+
help="run the server only (no desktop window)",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument("--port", type=int, default=0, help="bind port (0 = ephemeral)")
|
|
47
|
+
args = parser.parse_args()
|
|
48
|
+
|
|
49
|
+
port = args.port or _free_port()
|
|
50
|
+
app = build_app(persist=True)
|
|
51
|
+
config = uvicorn.Config(
|
|
52
|
+
app,
|
|
53
|
+
host=HOST,
|
|
54
|
+
port=port,
|
|
55
|
+
log_level="warning",
|
|
56
|
+
ws="websockets-sansio", # avoid the deprecated legacy websockets impl
|
|
57
|
+
)
|
|
58
|
+
server = uvicorn.Server(config)
|
|
59
|
+
|
|
60
|
+
thread = threading.Thread(target=_serve, args=(server,), daemon=True)
|
|
61
|
+
thread.start()
|
|
62
|
+
while not server.started:
|
|
63
|
+
time.sleep(0.02)
|
|
64
|
+
|
|
65
|
+
url = f"http://{HOST}:{port}/?t={SESSION_TOKEN}"
|
|
66
|
+
log.info("terminux server ready at %s", url)
|
|
67
|
+
|
|
68
|
+
if args.no_window:
|
|
69
|
+
with contextlib.suppress(KeyboardInterrupt):
|
|
70
|
+
thread.join()
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
_run_windowed(url, app.state.controller, server)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _run_windowed(url: str, ctl: AppController, server: uvicorn.Server) -> None:
|
|
77
|
+
import webview # noqa: PLC0415 (heavy GUI import; only when windowing)
|
|
78
|
+
from webview.dom import DOMEventHandler # noqa: PLC0415
|
|
79
|
+
|
|
80
|
+
window = webview.create_window("terminux", url, width=1100, height=720)
|
|
81
|
+
if window is None:
|
|
82
|
+
msg = "failed to create application window"
|
|
83
|
+
raise RuntimeError(msg)
|
|
84
|
+
|
|
85
|
+
def _on_drop(event: dict[str, object]) -> None:
|
|
86
|
+
# WKWebView hides file paths from JS; pywebview injects the real path
|
|
87
|
+
# as ``pywebviewFullPath`` only for a Python drop handler (registering
|
|
88
|
+
# one also enables the native Cocoa path capture). Route them to the
|
|
89
|
+
# active terminal, shell-quoted — drop a file into Claude Code.
|
|
90
|
+
# ``event`` is pywebview's loosely-typed JSON event dict.
|
|
91
|
+
if not isinstance(event, dict):
|
|
92
|
+
return
|
|
93
|
+
transfer = event.get("dataTransfer")
|
|
94
|
+
files = (
|
|
95
|
+
cast("dict[str, object]", transfer).get("files")
|
|
96
|
+
if isinstance(transfer, dict)
|
|
97
|
+
else None
|
|
98
|
+
)
|
|
99
|
+
paths: list[str] = []
|
|
100
|
+
for f in files if isinstance(files, list) else []:
|
|
101
|
+
if isinstance(f, dict):
|
|
102
|
+
p = cast("dict[str, object]", f).get("pywebviewFullPath")
|
|
103
|
+
if isinstance(p, str):
|
|
104
|
+
paths.append(p)
|
|
105
|
+
if paths:
|
|
106
|
+
ctl.paste_paths(paths)
|
|
107
|
+
|
|
108
|
+
def _register_drop() -> None:
|
|
109
|
+
# Must run after the DOM is loaded; prevent_default stops the webview
|
|
110
|
+
# from navigating to the dropped file.
|
|
111
|
+
window.dom.document.on("drop", DOMEventHandler(_on_drop, prevent_default=True))
|
|
112
|
+
|
|
113
|
+
def _shutdown() -> None:
|
|
114
|
+
ctl.terminals.close_all()
|
|
115
|
+
server.should_exit = True
|
|
116
|
+
|
|
117
|
+
window.events.loaded += _register_drop
|
|
118
|
+
window.events.closed += _shutdown
|
|
119
|
+
webview.start()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
main()
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""In-memory authoritative app state: workspaces and tabs.
|
|
2
|
+
|
|
3
|
+
Only structure (ids, names, order, active selection, UI prefs) is persisted.
|
|
4
|
+
Live terminals are transient and rebuilt on demand; see ``core.terminal``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _new_id() -> str:
|
|
16
|
+
return uuid.uuid4().hex
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WorkspaceStatus(str, Enum):
|
|
20
|
+
"""Auto-derived sidebar status (functional spec §4.4)."""
|
|
21
|
+
|
|
22
|
+
ACTIVE = "active"
|
|
23
|
+
RUNNING = "running"
|
|
24
|
+
IDLE = "idle"
|
|
25
|
+
EXITED = "exited"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Tab:
|
|
30
|
+
"""One terminal session inside a workspace."""
|
|
31
|
+
|
|
32
|
+
id: str = field(default_factory=_new_id)
|
|
33
|
+
title: str = "shell"
|
|
34
|
+
user_set_title: bool = False
|
|
35
|
+
# Transient (never persisted):
|
|
36
|
+
terminal_id: str | None = None
|
|
37
|
+
has_unseen_output: bool = False
|
|
38
|
+
spawn_cwd: str | None = None # directory inherited from the previous tab
|
|
39
|
+
|
|
40
|
+
def to_json(self) -> dict[str, Any]:
|
|
41
|
+
return {
|
|
42
|
+
"id": self.id,
|
|
43
|
+
"title": self.title,
|
|
44
|
+
"user_set_title": self.user_set_title,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_json(cls, data: dict[str, Any]) -> Tab:
|
|
49
|
+
return cls(
|
|
50
|
+
id=str(data["id"]),
|
|
51
|
+
title=str(data.get("title", "shell")),
|
|
52
|
+
user_set_title=bool(data.get("user_set_title")),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Workspace:
|
|
58
|
+
"""A named container for a set of terminal tabs."""
|
|
59
|
+
|
|
60
|
+
id: str = field(default_factory=_new_id)
|
|
61
|
+
name: str = "workspace"
|
|
62
|
+
tab_ids: list[str] = field(default_factory=list)
|
|
63
|
+
active_tab_id: str | None = None
|
|
64
|
+
# Transient: set when a tab produces output while this workspace is not active.
|
|
65
|
+
has_unseen_output: bool = False
|
|
66
|
+
|
|
67
|
+
def to_json(self) -> dict[str, Any]:
|
|
68
|
+
return {
|
|
69
|
+
"id": self.id,
|
|
70
|
+
"name": self.name,
|
|
71
|
+
"tab_ids": list(self.tab_ids),
|
|
72
|
+
"active_tab_id": self.active_tab_id,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_json(cls, data: dict[str, Any]) -> Workspace:
|
|
77
|
+
return cls(
|
|
78
|
+
id=str(data["id"]),
|
|
79
|
+
name=str(data.get("name", "workspace")),
|
|
80
|
+
tab_ids=[str(t) for t in data.get("tab_ids", [])],
|
|
81
|
+
active_tab_id=(
|
|
82
|
+
str(data["active_tab_id"])
|
|
83
|
+
if data.get("active_tab_id") is not None
|
|
84
|
+
else None
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class UiPrefs:
|
|
91
|
+
"""Persisted UI preferences."""
|
|
92
|
+
|
|
93
|
+
sidebar_width: int = 220
|
|
94
|
+
sidebar_collapsed: bool = False
|
|
95
|
+
font_size: int = 13
|
|
96
|
+
|
|
97
|
+
def to_json(self) -> dict[str, Any]:
|
|
98
|
+
return {
|
|
99
|
+
"sidebar_width": self.sidebar_width,
|
|
100
|
+
"sidebar_collapsed": self.sidebar_collapsed,
|
|
101
|
+
"font_size": self.font_size,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_json(cls, data: dict[str, Any]) -> UiPrefs:
|
|
106
|
+
return cls(
|
|
107
|
+
sidebar_width=int(data.get("sidebar_width", 220)),
|
|
108
|
+
sidebar_collapsed=bool(data.get("sidebar_collapsed")),
|
|
109
|
+
font_size=int(data.get("font_size", 13)),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
SCHEMA_VERSION = 1
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class AppState:
|
|
118
|
+
"""Authoritative state. All mutation happens on the backend event loop."""
|
|
119
|
+
|
|
120
|
+
workspaces: list[Workspace] = field(default_factory=list)
|
|
121
|
+
tabs: dict[str, Tab] = field(default_factory=dict)
|
|
122
|
+
active_workspace_id: str | None = None
|
|
123
|
+
ui: UiPrefs = field(default_factory=UiPrefs)
|
|
124
|
+
|
|
125
|
+
# ----- construction -------------------------------------------------
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def default(cls) -> AppState:
|
|
129
|
+
"""A single workspace with one tab — the always-valid baseline."""
|
|
130
|
+
state = cls()
|
|
131
|
+
ws = state.add_workspace(name="workspace 1")
|
|
132
|
+
state.add_tab(ws.id)
|
|
133
|
+
state.active_workspace_id = ws.id
|
|
134
|
+
return state
|
|
135
|
+
|
|
136
|
+
# ----- workspace ops ------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def add_workspace(self, name: str | None = None) -> Workspace:
|
|
139
|
+
ws = Workspace(name=name or self._next_workspace_name())
|
|
140
|
+
self.workspaces.append(ws)
|
|
141
|
+
return ws
|
|
142
|
+
|
|
143
|
+
def _next_workspace_name(self) -> str:
|
|
144
|
+
used = {w.name for w in self.workspaces}
|
|
145
|
+
i = 1
|
|
146
|
+
while f"workspace {i}" in used:
|
|
147
|
+
i += 1
|
|
148
|
+
return f"workspace {i}"
|
|
149
|
+
|
|
150
|
+
def get_workspace(self, ws_id: str) -> Workspace | None:
|
|
151
|
+
return next((w for w in self.workspaces if w.id == ws_id), None)
|
|
152
|
+
|
|
153
|
+
def remove_workspace(self, ws_id: str) -> list[str]:
|
|
154
|
+
"""Remove a workspace; return the ids of its tabs (caller kills terminals)."""
|
|
155
|
+
ws = self.get_workspace(ws_id)
|
|
156
|
+
if ws is None:
|
|
157
|
+
return []
|
|
158
|
+
tab_ids = list(ws.tab_ids)
|
|
159
|
+
for tid in tab_ids:
|
|
160
|
+
self.tabs.pop(tid, None)
|
|
161
|
+
self.workspaces = [w for w in self.workspaces if w.id != ws_id]
|
|
162
|
+
if self.active_workspace_id == ws_id:
|
|
163
|
+
self.active_workspace_id = (
|
|
164
|
+
self.workspaces[0].id if self.workspaces else None
|
|
165
|
+
)
|
|
166
|
+
return tab_ids
|
|
167
|
+
|
|
168
|
+
def set_active_workspace(self, ws_id: str) -> None:
|
|
169
|
+
if self.get_workspace(ws_id) is None:
|
|
170
|
+
return
|
|
171
|
+
self.active_workspace_id = ws_id
|
|
172
|
+
ws = self.get_workspace(ws_id)
|
|
173
|
+
if ws is not None:
|
|
174
|
+
ws.has_unseen_output = False
|
|
175
|
+
for tid in ws.tab_ids:
|
|
176
|
+
tab = self.tabs.get(tid)
|
|
177
|
+
if tab is not None and tid == ws.active_tab_id:
|
|
178
|
+
tab.has_unseen_output = False
|
|
179
|
+
|
|
180
|
+
# ----- tab ops ------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def add_tab(
|
|
183
|
+
self,
|
|
184
|
+
ws_id: str,
|
|
185
|
+
title: str = "shell",
|
|
186
|
+
spawn_cwd: str | None = None,
|
|
187
|
+
) -> Tab | None:
|
|
188
|
+
ws = self.get_workspace(ws_id)
|
|
189
|
+
if ws is None:
|
|
190
|
+
return None
|
|
191
|
+
tab = Tab(title=title, spawn_cwd=spawn_cwd)
|
|
192
|
+
self.tabs[tab.id] = tab
|
|
193
|
+
ws.tab_ids.append(tab.id)
|
|
194
|
+
ws.active_tab_id = tab.id
|
|
195
|
+
return tab
|
|
196
|
+
|
|
197
|
+
def remove_tab(self, tab_id: str) -> None:
|
|
198
|
+
for ws in self.workspaces:
|
|
199
|
+
if tab_id in ws.tab_ids:
|
|
200
|
+
ws.tab_ids.remove(tab_id)
|
|
201
|
+
if ws.active_tab_id == tab_id:
|
|
202
|
+
ws.active_tab_id = ws.tab_ids[-1] if ws.tab_ids else None
|
|
203
|
+
self.tabs.pop(tab_id, None)
|
|
204
|
+
|
|
205
|
+
# ----- status -------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def workspace_status(self, ws_id: str) -> WorkspaceStatus:
|
|
208
|
+
ws = self.get_workspace(ws_id)
|
|
209
|
+
if ws is None:
|
|
210
|
+
return WorkspaceStatus.IDLE
|
|
211
|
+
if ws_id == self.active_workspace_id:
|
|
212
|
+
return WorkspaceStatus.ACTIVE
|
|
213
|
+
has_live = any(
|
|
214
|
+
(t := self.tabs.get(tid)) is not None and t.terminal_id is not None
|
|
215
|
+
for tid in ws.tab_ids
|
|
216
|
+
)
|
|
217
|
+
if not has_live and ws.tab_ids:
|
|
218
|
+
return WorkspaceStatus.EXITED
|
|
219
|
+
if ws.has_unseen_output:
|
|
220
|
+
return WorkspaceStatus.RUNNING
|
|
221
|
+
return WorkspaceStatus.IDLE
|
|
222
|
+
|
|
223
|
+
# ----- serialization ------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def to_json(self) -> dict[str, Any]:
|
|
226
|
+
return {
|
|
227
|
+
"version": SCHEMA_VERSION,
|
|
228
|
+
"workspaces": [w.to_json() for w in self.workspaces],
|
|
229
|
+
"tabs": [t.to_json() for t in self.tabs.values()],
|
|
230
|
+
"active_workspace_id": self.active_workspace_id,
|
|
231
|
+
"ui": self.ui.to_json(),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def from_json(cls, data: dict[str, Any]) -> AppState:
|
|
236
|
+
tabs = {}
|
|
237
|
+
for raw in data.get("tabs", []):
|
|
238
|
+
tab = Tab.from_json(raw)
|
|
239
|
+
tabs[tab.id] = tab
|
|
240
|
+
workspaces = [Workspace.from_json(w) for w in data.get("workspaces", [])]
|
|
241
|
+
state = cls(
|
|
242
|
+
workspaces=workspaces,
|
|
243
|
+
tabs=tabs,
|
|
244
|
+
active_workspace_id=data.get("active_workspace_id"),
|
|
245
|
+
ui=UiPrefs.from_json(data.get("ui", {})),
|
|
246
|
+
)
|
|
247
|
+
state._repair()
|
|
248
|
+
return state
|
|
249
|
+
|
|
250
|
+
def _repair(self) -> AppState:
|
|
251
|
+
"""Drop dangling references; guarantee >= 1 workspace with >= 1 tab."""
|
|
252
|
+
for ws in self.workspaces:
|
|
253
|
+
ws.tab_ids = [tid for tid in ws.tab_ids if tid in self.tabs]
|
|
254
|
+
if ws.active_tab_id not in ws.tab_ids:
|
|
255
|
+
ws.active_tab_id = ws.tab_ids[-1] if ws.tab_ids else None
|
|
256
|
+
if not ws.tab_ids:
|
|
257
|
+
tab = Tab()
|
|
258
|
+
self.tabs[tab.id] = tab
|
|
259
|
+
ws.tab_ids.append(tab.id)
|
|
260
|
+
ws.active_tab_id = tab.id
|
|
261
|
+
# drop orphan tabs not referenced by any workspace
|
|
262
|
+
referenced = {tid for ws in self.workspaces for tid in ws.tab_ids}
|
|
263
|
+
self.tabs = {tid: t for tid, t in self.tabs.items() if tid in referenced}
|
|
264
|
+
if not self.workspaces:
|
|
265
|
+
ws = self.add_workspace(name="workspace 1")
|
|
266
|
+
self.add_tab(ws.id)
|
|
267
|
+
if self.get_workspace(self.active_workspace_id or "") is None:
|
|
268
|
+
self.active_workspace_id = self.workspaces[0].id
|
|
269
|
+
return self
|
|
270
|
+
|
|
271
|
+
def view_json(self) -> dict[str, Any]:
|
|
272
|
+
"""Snapshot for the frontend, including derived status."""
|
|
273
|
+
return {
|
|
274
|
+
"workspaces": [
|
|
275
|
+
{
|
|
276
|
+
**w.to_json(),
|
|
277
|
+
"status": self.workspace_status(w.id).value,
|
|
278
|
+
}
|
|
279
|
+
for w in self.workspaces
|
|
280
|
+
],
|
|
281
|
+
"tabs": {
|
|
282
|
+
tid: {
|
|
283
|
+
**t.to_json(),
|
|
284
|
+
"live": t.terminal_id is not None,
|
|
285
|
+
"has_unseen_output": t.has_unseen_output,
|
|
286
|
+
}
|
|
287
|
+
for tid, t in self.tabs.items()
|
|
288
|
+
},
|
|
289
|
+
"active_workspace_id": self.active_workspace_id,
|
|
290
|
+
"ui": self.ui.to_json(),
|
|
291
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Atomic, versioned JSON persistence of structural state.
|
|
2
|
+
|
|
3
|
+
Live processes and scrollback are never persisted (functional spec §7):
|
|
4
|
+
a restored tab starts a fresh shell.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import platformdirs
|
|
17
|
+
|
|
18
|
+
from terminux.core.model import AppState
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def state_path() -> Path:
|
|
24
|
+
data_dir = Path(platformdirs.user_data_dir("terminux"))
|
|
25
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
return data_dir / "state.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_state(path: Path | None = None) -> AppState:
|
|
30
|
+
"""Load persisted state, falling back to a sane default on any error."""
|
|
31
|
+
path = path or state_path()
|
|
32
|
+
try:
|
|
33
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
34
|
+
return AppState.from_json(raw)
|
|
35
|
+
except FileNotFoundError:
|
|
36
|
+
log.info("no persisted state at %s; starting fresh", path)
|
|
37
|
+
except (json.JSONDecodeError, KeyError, TypeError, ValueError, OSError) as exc:
|
|
38
|
+
log.warning("could not load state from %s (%s); starting fresh", path, exc)
|
|
39
|
+
return AppState.default()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def save_state(state: AppState, path: Path | None = None) -> None:
|
|
43
|
+
"""Write state atomically (temp file + os.replace)."""
|
|
44
|
+
path = path or state_path()
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
payload = json.dumps(state.to_json(), indent=2)
|
|
47
|
+
fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=".state-", suffix=".tmp")
|
|
48
|
+
tmp_path = Path(tmp_name)
|
|
49
|
+
try:
|
|
50
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
51
|
+
fh.write(payload)
|
|
52
|
+
fh.flush()
|
|
53
|
+
os.fsync(fh.fileno())
|
|
54
|
+
tmp_path.replace(path)
|
|
55
|
+
except OSError:
|
|
56
|
+
log.exception("failed to persist state to %s", path)
|
|
57
|
+
with contextlib.suppress(OSError):
|
|
58
|
+
tmp_path.unlink(missing_ok=True)
|