forestui 0.9.0__py3-none-any.whl

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.
@@ -0,0 +1,84 @@
1
+ """Settings service for managing application configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from forestui.models import Settings
9
+
10
+ # Default forest directory
11
+ DEFAULT_FOREST_PATH = Path.home() / "forest"
12
+
13
+ # Runtime forest path (set at startup via CLI argument)
14
+ _forest_path: Path | None = None
15
+
16
+
17
+ def set_forest_path(path: Path | str | None) -> None:
18
+ """Set the runtime forest path."""
19
+ global _forest_path
20
+ if path is None:
21
+ _forest_path = DEFAULT_FOREST_PATH
22
+ else:
23
+ _forest_path = Path(path).expanduser().resolve()
24
+
25
+
26
+ def get_forest_path() -> Path:
27
+ """Get the current forest path."""
28
+ if _forest_path is None:
29
+ return DEFAULT_FOREST_PATH
30
+ return _forest_path
31
+
32
+
33
+ class SettingsService:
34
+ """Service for managing application settings."""
35
+
36
+ _instance: SettingsService | None = None
37
+ _settings: Settings | None = None
38
+ _config_path: Path = Path.home() / ".config" / "forestui" / "settings.json"
39
+
40
+ def __new__(cls) -> SettingsService:
41
+ if cls._instance is None:
42
+ cls._instance = super().__new__(cls)
43
+ return cls._instance
44
+
45
+ def __init__(self) -> None:
46
+ if self._settings is None:
47
+ self._settings = self._load_settings()
48
+
49
+ def _load_settings(self) -> Settings:
50
+ """Load settings from config file."""
51
+ if self._config_path.exists():
52
+ try:
53
+ with self._config_path.open(encoding="utf-8") as f:
54
+ data = json.load(f)
55
+ return Settings.model_validate(data)
56
+ except (json.JSONDecodeError, OSError):
57
+ pass
58
+ return Settings.default()
59
+
60
+ def save_settings(self, settings: Settings) -> None:
61
+ """Save settings to config file."""
62
+ self._config_path.parent.mkdir(parents=True, exist_ok=True)
63
+ with self._config_path.open("w", encoding="utf-8") as f:
64
+ json.dump(settings.model_dump(), f, indent=2)
65
+ self._settings = settings
66
+
67
+ @property
68
+ def settings(self) -> Settings:
69
+ """Get current settings."""
70
+ if self._settings is None:
71
+ self._settings = self._load_settings()
72
+ return self._settings
73
+
74
+ def update(self, **kwargs: str) -> None:
75
+ """Update settings with new values."""
76
+ current = self.settings.model_dump()
77
+ current.update(kwargs)
78
+ new_settings = Settings.model_validate(current)
79
+ self.save_settings(new_settings)
80
+
81
+
82
+ def get_settings_service() -> SettingsService:
83
+ """Get the singleton SettingsService instance."""
84
+ return SettingsService()
@@ -0,0 +1,320 @@
1
+ """Service for tmux integration using libtmux."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+
8
+ from libtmux import Server
9
+ from libtmux.exc import LibTmuxException
10
+ from libtmux.session import Session
11
+ from libtmux.window import Window
12
+
13
+ TUI_EDITORS = {
14
+ "vim",
15
+ "nvim",
16
+ "vi",
17
+ "emacs",
18
+ "nano",
19
+ "helix",
20
+ "hx",
21
+ "micro",
22
+ "kakoune",
23
+ "kak",
24
+ }
25
+
26
+
27
+ class TmuxService:
28
+ """Service for interacting with tmux sessions via libtmux."""
29
+
30
+ _instance: TmuxService | None = None
31
+ _server: Server | None = None
32
+ _session: Session | None = None
33
+
34
+ def __new__(cls) -> TmuxService:
35
+ if cls._instance is None:
36
+ cls._instance = super().__new__(cls)
37
+ return cls._instance
38
+
39
+ @property
40
+ def is_inside_tmux(self) -> bool:
41
+ """Check if we are running inside a tmux session."""
42
+ return bool(os.environ.get("TMUX"))
43
+
44
+ @property
45
+ def server(self) -> Server | None:
46
+ """Get the tmux server."""
47
+ if not self.is_inside_tmux:
48
+ return None
49
+ if self._server is None:
50
+ try:
51
+ self._server = Server()
52
+ except LibTmuxException:
53
+ return None
54
+ return self._server
55
+
56
+ @property
57
+ def session(self) -> Session | None:
58
+ """Get the current tmux session."""
59
+ if not self.is_inside_tmux or self.server is None:
60
+ return None
61
+ if self._session is None:
62
+ try:
63
+ # Get session from TMUX environment variable
64
+ # Format: /socket/path,pid,window_index
65
+ tmux_env = os.environ.get("TMUX", "")
66
+ if tmux_env:
67
+ # Find the attached session (session_attached is a count > 0)
68
+ for sess in self.server.sessions:
69
+ attached = sess.session_attached
70
+ if attached and int(attached) > 0:
71
+ self._session = sess
72
+ break
73
+ # Fallback to first session if none found
74
+ if self._session is None and self.server.sessions:
75
+ self._session = self.server.sessions[0]
76
+ except (LibTmuxException, ValueError, TypeError):
77
+ return None
78
+ return self._session
79
+
80
+ @property
81
+ def current_window(self) -> Window | None:
82
+ """Get the current tmux window."""
83
+ if self.session is None:
84
+ return None
85
+ try:
86
+ return self.session.active_window
87
+ except LibTmuxException:
88
+ return None
89
+
90
+ def rename_window(self, name: str) -> bool:
91
+ """Rename the current tmux window."""
92
+ window = self.current_window
93
+ if window is None:
94
+ return False
95
+ try:
96
+ window.rename_window(name)
97
+ return True
98
+ except LibTmuxException:
99
+ return False
100
+
101
+ def ensure_focus_events(self) -> bool:
102
+ """Ensure tmux focus-events option is enabled for proper app refresh.
103
+
104
+ Returns:
105
+ True if focus events were enabled successfully, False otherwise.
106
+ """
107
+ if self.server is None:
108
+ return False
109
+ try:
110
+ self.server.cmd("set-option", "-g", "focus-events", "on")
111
+ return True
112
+ except LibTmuxException:
113
+ return False
114
+
115
+ def is_tui_editor(self, editor: str) -> bool:
116
+ """Check if an editor is a TUI editor that should run in tmux."""
117
+ # Get base command (handle "emacs -nw" -> "emacs")
118
+ base_cmd = editor.split()[0]
119
+ return base_cmd in TUI_EDITORS
120
+
121
+ def find_window(self, name: str) -> Window | None:
122
+ """Find a window by name in the current session."""
123
+ if self.session is None:
124
+ return None
125
+ try:
126
+ for window in self.session.windows:
127
+ if window.window_name == name:
128
+ return window
129
+ except LibTmuxException:
130
+ pass
131
+ return None
132
+
133
+ def create_editor_window(
134
+ self,
135
+ worktree_name: str,
136
+ worktree_path: str,
137
+ editor: str,
138
+ ) -> bool:
139
+ """Create a tmux window with the editor open in the worktree.
140
+
141
+ Args:
142
+ worktree_name: Name of the worktree (used for window naming)
143
+ worktree_path: Path to the worktree directory
144
+ editor: Editor command to run
145
+
146
+ Returns:
147
+ True if window was created/selected successfully, False otherwise
148
+ """
149
+ if self.session is None:
150
+ return False
151
+
152
+ window_name = f"edit:{worktree_name}"
153
+
154
+ try:
155
+ # Check if window already exists
156
+ existing_window = self.find_window(window_name)
157
+ if existing_window is not None:
158
+ existing_window.select()
159
+ return True
160
+
161
+ # Create new window with editor as the command (closes when editor exits)
162
+ self.session.new_window(
163
+ window_name=window_name,
164
+ start_directory=worktree_path,
165
+ attach=True,
166
+ window_shell=f"{editor} .",
167
+ )
168
+
169
+ return True
170
+
171
+ except LibTmuxException:
172
+ return False
173
+
174
+ def create_shell_window(self, name: str, path: str) -> bool:
175
+ """Create a tmux window with a shell.
176
+
177
+ Args:
178
+ name: Name for the window
179
+ path: Working directory path
180
+
181
+ Returns:
182
+ True if window was created successfully, False otherwise
183
+ """
184
+ if self.session is None:
185
+ return False
186
+
187
+ base_window_name = f"term:{name}"
188
+
189
+ try:
190
+ # Always create a new window with unique name
191
+ window_name = self._find_unique_window_name(base_window_name)
192
+
193
+ self.session.new_window(
194
+ window_name=window_name,
195
+ start_directory=path,
196
+ attach=True,
197
+ )
198
+
199
+ return True
200
+
201
+ except LibTmuxException:
202
+ return False
203
+
204
+ def create_mc_window(self, name: str, path: str) -> bool:
205
+ """Create a tmux window with Midnight Commander.
206
+
207
+ Args:
208
+ name: Name for the window
209
+ path: Working directory path
210
+
211
+ Returns:
212
+ True if window was created successfully, False otherwise
213
+ """
214
+ if self.session is None:
215
+ return False
216
+
217
+ base_window_name = f"files:{name}"
218
+
219
+ try:
220
+ # Always create a new window with unique name
221
+ window_name = self._find_unique_window_name(base_window_name)
222
+
223
+ self.session.new_window(
224
+ window_name=window_name,
225
+ start_directory=path,
226
+ attach=True,
227
+ window_shell="mc",
228
+ )
229
+
230
+ return True
231
+
232
+ except LibTmuxException:
233
+ return False
234
+
235
+ def _find_unique_window_name(self, base_name: str) -> str:
236
+ """Find a unique window name by adding :2, :3, etc. suffix if needed.
237
+
238
+ Args:
239
+ base_name: The base window name (e.g., "yolo:cogram")
240
+
241
+ Returns:
242
+ A unique window name (e.g., "yolo:cogram" or "yolo:cogram:2")
243
+ """
244
+ if self.session is None:
245
+ return base_name
246
+
247
+ try:
248
+ existing_names = {w.window_name for w in self.session.windows}
249
+ except LibTmuxException:
250
+ return base_name
251
+
252
+ if base_name not in existing_names:
253
+ return base_name
254
+
255
+ # Find next available suffix
256
+ counter = 2
257
+ while f"{base_name}:{counter}" in existing_names:
258
+ counter += 1
259
+ return f"{base_name}:{counter}"
260
+
261
+ def create_claude_window(
262
+ self,
263
+ name: str,
264
+ path: str,
265
+ resume_session_id: str | None = None,
266
+ yolo: bool = False,
267
+ custom_command: str | None = None,
268
+ ) -> str | None:
269
+ """Create a tmux window with Claude Code.
270
+
271
+ Args:
272
+ name: Name for the window (e.g., worktree name)
273
+ path: Working directory path
274
+ resume_session_id: Optional session ID to resume
275
+ yolo: If True, add --dangerously-skip-permissions flag
276
+ custom_command: Optional custom Claude command (e.g., "claude --model opus")
277
+
278
+ Returns:
279
+ The window name if created/selected successfully, None otherwise
280
+ """
281
+ if self.session is None:
282
+ return None
283
+
284
+ base_window_name = f"claude:{name}"
285
+ if yolo:
286
+ base_window_name = f"yolo:{name}"
287
+
288
+ try:
289
+ # Always create a new window with unique name (add :2, :3 suffix if needed)
290
+ window_name = self._find_unique_window_name(base_window_name)
291
+
292
+ # Build claude command (closes when claude exits)
293
+ # Use custom_command if provided, otherwise default to "claude"
294
+ cmd = custom_command or "claude"
295
+ if yolo:
296
+ cmd += " --dangerously-skip-permissions"
297
+ if resume_session_id:
298
+ cmd += f" -r {resume_session_id}"
299
+
300
+ # Wrap in interactive shell to support aliases
301
+ # Use shlex.quote to prevent shell injection from custom commands
302
+ shell = os.environ.get("SHELL", "/bin/bash")
303
+ shell_cmd = f"{shell} -ic {shlex.quote(cmd)}"
304
+
305
+ self.session.new_window(
306
+ window_name=window_name,
307
+ start_directory=path,
308
+ attach=True,
309
+ window_shell=shell_cmd,
310
+ )
311
+
312
+ return window_name
313
+
314
+ except LibTmuxException:
315
+ return None
316
+
317
+
318
+ def get_tmux_service() -> TmuxService:
319
+ """Get the singleton TmuxService instance."""
320
+ return TmuxService()
forestui/state.py ADDED
@@ -0,0 +1,248 @@
1
+ """Application state management for forestui."""
2
+
3
+ import json
4
+ from datetime import UTC, datetime
5
+ from pathlib import Path
6
+ from uuid import UUID
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from forestui.models import Repository, Selection, Worktree
11
+ from forestui.services.settings import get_forest_path
12
+
13
+
14
+ class AppStateData(BaseModel):
15
+ """Serializable app state data."""
16
+
17
+ repositories: list[Repository] = []
18
+
19
+
20
+ class AppState:
21
+ """Centralized application state with persistence."""
22
+
23
+ def __init__(self) -> None:
24
+ self._repositories: list[Repository] = []
25
+ self._selection: Selection = Selection()
26
+ self._show_archived: bool = False
27
+ self._load_state()
28
+
29
+ def _get_config_path(self) -> Path:
30
+ """Get the config file path."""
31
+ forest_dir = get_forest_path()
32
+ forest_dir.mkdir(parents=True, exist_ok=True)
33
+ return forest_dir / ".forestui-config.json"
34
+
35
+ def _load_state(self) -> None:
36
+ """Load state from config file."""
37
+ config_path = self._get_config_path()
38
+ if config_path.exists():
39
+ try:
40
+ with config_path.open(encoding="utf-8") as f:
41
+ data = json.load(f)
42
+ state_data = AppStateData.model_validate(data)
43
+ self._repositories = state_data.repositories
44
+ except (json.JSONDecodeError, OSError):
45
+ pass
46
+
47
+ def _save_state(self) -> None:
48
+ """Save state to config file."""
49
+ config_path = self._get_config_path()
50
+ config_path.parent.mkdir(parents=True, exist_ok=True)
51
+ state_data = AppStateData(repositories=self._repositories)
52
+ with config_path.open("w", encoding="utf-8") as f:
53
+ json.dump(state_data.model_dump(mode="json"), f, indent=2, default=str)
54
+
55
+ @property
56
+ def repositories(self) -> list[Repository]:
57
+ """Get all repositories."""
58
+ return self._repositories
59
+
60
+ @property
61
+ def selection(self) -> Selection:
62
+ """Get current selection."""
63
+ return self._selection
64
+
65
+ @selection.setter
66
+ def selection(self, value: Selection) -> None:
67
+ """Set current selection."""
68
+ self._selection = value
69
+
70
+ @property
71
+ def show_archived(self) -> bool:
72
+ """Get show archived flag."""
73
+ return self._show_archived
74
+
75
+ @show_archived.setter
76
+ def show_archived(self, value: bool) -> None:
77
+ """Set show archived flag."""
78
+ self._show_archived = value
79
+
80
+ def add_repository(self, repository: Repository) -> None:
81
+ """Add a repository."""
82
+ self._repositories.append(repository)
83
+ self._save_state()
84
+
85
+ def remove_repository(self, repo_id: UUID) -> None:
86
+ """Remove a repository by ID."""
87
+ self._repositories = [r for r in self._repositories if r.id != repo_id]
88
+ if self._selection.repository_id == repo_id:
89
+ self._selection = Selection()
90
+ self._save_state()
91
+
92
+ def find_repository(self, repo_id: UUID) -> Repository | None:
93
+ """Find a repository by ID."""
94
+ for repo in self._repositories:
95
+ if repo.id == repo_id:
96
+ return repo
97
+ return None
98
+
99
+ def update_repository_command(self, repo_id: UUID, command: str | None) -> bool:
100
+ """Update a repository's custom Claude command.
101
+
102
+ Returns:
103
+ True if the repository was found and updated, False otherwise.
104
+ """
105
+ for repo in self._repositories:
106
+ if repo.id == repo_id:
107
+ repo.custom_claude_command = command
108
+ self._save_state()
109
+ return True
110
+ return False
111
+
112
+ def update_worktree_command(self, worktree_id: UUID, command: str | None) -> bool:
113
+ """Update a worktree's custom Claude command.
114
+
115
+ Returns:
116
+ True if the worktree was found and updated, False otherwise.
117
+ """
118
+ for repo in self._repositories:
119
+ for worktree in repo.worktrees:
120
+ if worktree.id == worktree_id:
121
+ worktree.custom_claude_command = command
122
+ self._save_state()
123
+ return True
124
+ return False
125
+
126
+ def find_worktree(self, worktree_id: UUID) -> tuple[Repository, Worktree] | None:
127
+ """Find a worktree by ID and return with its parent repository."""
128
+ for repo in self._repositories:
129
+ for worktree in repo.worktrees:
130
+ if worktree.id == worktree_id:
131
+ return repo, worktree
132
+ return None
133
+
134
+ def add_worktree(self, repo_id: UUID, worktree: Worktree) -> None:
135
+ """Add a worktree to a repository."""
136
+ for repo in self._repositories:
137
+ if repo.id == repo_id:
138
+ repo.worktrees.append(worktree)
139
+ self._save_state()
140
+ return
141
+
142
+ def remove_worktree(self, worktree_id: UUID) -> None:
143
+ """Remove a worktree by ID."""
144
+ for repo in self._repositories:
145
+ repo.worktrees = [w for w in repo.worktrees if w.id != worktree_id]
146
+ if self._selection.worktree_id == worktree_id:
147
+ self._selection = Selection(repository_id=self._selection.repository_id)
148
+ self._save_state()
149
+
150
+ def update_worktree(
151
+ self, worktree_id: UUID, **kwargs: str | bool | int | None
152
+ ) -> None:
153
+ """Update a worktree's attributes."""
154
+ for repo in self._repositories:
155
+ for i, worktree in enumerate(repo.worktrees):
156
+ if worktree.id == worktree_id:
157
+ data = worktree.model_dump()
158
+ data.update(kwargs)
159
+ repo.worktrees[i] = Worktree.model_validate(data)
160
+ self._save_state()
161
+ return
162
+
163
+ def archive_worktree(self, worktree_id: UUID) -> None:
164
+ """Archive a worktree."""
165
+ self.update_worktree(worktree_id, is_archived=True)
166
+
167
+ def unarchive_worktree(self, worktree_id: UUID) -> None:
168
+ """Unarchive a worktree."""
169
+ self.update_worktree(worktree_id, is_archived=False)
170
+
171
+ def select_repository(self, repo_id: UUID) -> None:
172
+ """Select a repository."""
173
+ self._selection = Selection(repository_id=repo_id)
174
+
175
+ def select_worktree(self, repo_id: UUID, worktree_id: UUID) -> None:
176
+ """Select a worktree."""
177
+ self._selection = Selection(repository_id=repo_id, worktree_id=worktree_id)
178
+
179
+ def clear_selection(self) -> None:
180
+ """Clear the current selection."""
181
+ self._selection = Selection()
182
+
183
+ @property
184
+ def selected_repository(self) -> Repository | None:
185
+ """Get the currently selected repository."""
186
+ if self._selection.repository_id:
187
+ return self.find_repository(self._selection.repository_id)
188
+ return None
189
+
190
+ @property
191
+ def selected_worktree(self) -> tuple[Repository, Worktree] | None:
192
+ """Get the currently selected worktree with its parent repo."""
193
+ if self._selection.worktree_id:
194
+ return self.find_worktree(self._selection.worktree_id)
195
+ return None
196
+
197
+ def has_archived_worktrees(self) -> bool:
198
+ """Check if there are any archived worktrees."""
199
+ for repo in self._repositories:
200
+ if any(w.is_archived for w in repo.worktrees):
201
+ return True
202
+ return False
203
+
204
+ def all_archived_worktrees(self) -> list[tuple[Repository, Worktree]]:
205
+ """Get all archived worktrees with their parent repositories."""
206
+ result: list[tuple[Repository, Worktree]] = []
207
+ for repo in self._repositories:
208
+ for worktree in repo.archived_worktrees():
209
+ result.append((repo, worktree))
210
+ return result
211
+
212
+ def reorder_worktree(
213
+ self, repo_id: UUID, worktree_id: UUID, new_index: int
214
+ ) -> None:
215
+ """Reorder a worktree within its repository."""
216
+ repo = self.find_repository(repo_id)
217
+ if not repo:
218
+ return
219
+
220
+ # Get active worktrees in current order
221
+ active = repo.active_worktrees()
222
+ worktree = next((w for w in active if w.id == worktree_id), None)
223
+ if not worktree:
224
+ return
225
+
226
+ # Remove and reinsert at new position
227
+ active = [w for w in active if w.id != worktree_id]
228
+ active.insert(min(new_index, len(active)), worktree)
229
+
230
+ # Update sort orders
231
+ for i, w in enumerate(active):
232
+ self.update_worktree(w.id, sort_order=i)
233
+
234
+ def refresh_worktree_timestamp(self, worktree_id: UUID) -> None:
235
+ """Update a worktree's last modified timestamp."""
236
+ self.update_worktree(worktree_id, last_modified=datetime.now(UTC).isoformat())
237
+
238
+
239
+ # Global app state instance
240
+ _app_state: AppState | None = None
241
+
242
+
243
+ def get_app_state() -> AppState:
244
+ """Get the global app state instance."""
245
+ global _app_state
246
+ if _app_state is None:
247
+ _app_state = AppState()
248
+ return _app_state