tasktree-manager 2.0.0__tar.gz → 2.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.
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/CHANGELOG.md +11 -7
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/PKG-INFO +1 -1
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/README.md +4 -4
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/docs/user-guide.md +2 -2
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/pyproject.toml +1 -1
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/scripts/bump_version.py +90 -1
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tasktree_manager/_version.py +2 -2
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tasktree_manager/app.py +112 -44
- tasktree_manager-2.1.0/tasktree_manager/services/claude_hooks.py +50 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tasktree_manager/widgets/status_panel.py +69 -2
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tasktree_manager/widgets/task_list.py +30 -6
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tasktree_manager/widgets/worktree_list.py +2 -2
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/SKILL.md +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/assets/README.md +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/assets/dashboard_app.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/assets/data_viewer.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/assets/todo_app.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/assets/worker_demo.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/references/layouts.md +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/references/official-guides-index.md +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/references/styling.md +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/references/widgets.md +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.github/templates/CHANGELOG.md.j2 +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.github/workflows/ci.yml +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.github/workflows/release.yml +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.gitignore +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/CONTRIBUTING.md +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/LICENSE +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/OPENSPEC.md +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/ROADMAP.md +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/docs/claude-code-integration-spec.md +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/docs/configuration.md +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/docs/installation.md +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/docs/troubleshooting.md +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/mise.toml +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tasktree_manager/__init__.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tasktree_manager/services/__init__.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tasktree_manager/services/config.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tasktree_manager/services/git_ops.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tasktree_manager/services/models.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tasktree_manager/services/task_manager.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tasktree_manager/widgets/__init__.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tasktree_manager/widgets/create_modal.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tasktree_manager/widgets/messages_panel.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tasktree_manager/widgets/setup_modal.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tests/__init__.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tests/conftest.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tests/test_app.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tests/test_config.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tests/test_git_ops.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tests/test_integration_git.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tests/test_memory.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tests/test_messages_panel.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tests/test_modals.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tests/test_setup_modal.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tests/test_status_panel.py +0 -0
- {tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tests/test_task_manager.py +0 -0
|
@@ -15,24 +15,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
15
15
|
- Git hooks support
|
|
16
16
|
- Custom task metadata
|
|
17
17
|
|
|
18
|
-
## [2.
|
|
18
|
+
## [2.1.0] - 2026-02-10
|
|
19
19
|
|
|
20
20
|
### Added
|
|
21
|
-
-
|
|
21
|
+
- Add context-aware info panel with task summary and focus-driven updates
|
|
22
|
+
- Add Claude session status monitoring with Ghostty integration
|
|
22
23
|
|
|
23
24
|
### Changed
|
|
24
|
-
-
|
|
25
|
+
- Update example task names in README and user guide
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## [2.0.1] - 2026-02-07
|
|
25
29
|
|
|
26
30
|
### Fixed
|
|
27
|
-
-
|
|
31
|
+
- Auto-generate changelog from conventional commits
|
|
28
32
|
|
|
29
33
|
|
|
30
|
-
## [
|
|
34
|
+
## [2.0.0] - 2026-02-07
|
|
31
35
|
|
|
32
36
|
### Changed
|
|
37
|
+
- **BREAKING**: CLI command renamed from `tasktree` to `tasktree-manager`
|
|
38
|
+
- **BREAKING**: Config directory moved from `~/.config/tasktree/` to `~/.config/tasktree-manager/`
|
|
33
39
|
- Renamed Python module from `tasktree` to `tasktree_manager` for PyPI consistency
|
|
34
|
-
- Renamed CLI command from `tasktree` to `tasktree-manager`
|
|
35
|
-
- Renamed config directory from `~/.config/tasktree/` to `~/.config/tasktree-manager/`
|
|
36
40
|
- Renamed GitHub repository to `tasktree-manager`
|
|
37
41
|
- Updated all documentation, CI workflows, and build configuration
|
|
38
42
|
|
|
@@ -24,13 +24,13 @@
|
|
|
24
24
|
```
|
|
25
25
|
┌─ tasktree-manager ──────────────────────────────────────────────────────┐
|
|
26
26
|
│ Tasks │ Worktrees │
|
|
27
|
-
│ ●
|
|
28
|
-
│
|
|
27
|
+
│ ● FEAT-42 (2) │ web-api FEAT-42 ✗ 3 │
|
|
28
|
+
│ FEAT-38-auth │ frontend FEAT-42 ✓ │
|
|
29
29
|
│ │ │
|
|
30
30
|
│ │ │
|
|
31
31
|
├──────────────────────────────────────────────────────────────────────────┤
|
|
32
|
-
│ Status:
|
|
33
|
-
│ Branch:
|
|
32
|
+
│ Status: web-api │
|
|
33
|
+
│ Branch: FEAT-42 │
|
|
34
34
|
│ Sync: ↑2 ↓1 │
|
|
35
35
|
│ │
|
|
36
36
|
│ M src/main.py │
|
|
@@ -60,7 +60,7 @@ Task: FEAT-123
|
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
**Characteristics:**
|
|
63
|
-
- Created with a unique name (e.g., `FEAT-123`, `
|
|
63
|
+
- Created with a unique name (e.g., `FEAT-123`, `BUG-456-auth`)
|
|
64
64
|
- Contains one or more worktrees
|
|
65
65
|
- Stored in `TASKS_DIR/task-name/`
|
|
66
66
|
- Each task gets its own set of branches across selected repos
|
|
@@ -696,7 +696,7 @@ gh pr create --head FEAT-123
|
|
|
696
696
|
### Naming Conventions
|
|
697
697
|
|
|
698
698
|
**Task names:**
|
|
699
|
-
- Use issue tracker IDs: `JIRA-1234`, `
|
|
699
|
+
- Use issue tracker IDs: `JIRA-1234`, `PROJ-567`
|
|
700
700
|
- Or descriptive names: `feat-auth`, `bugfix-login`, `refactor-api`
|
|
701
701
|
- Avoid spaces: Use `-` or `_`
|
|
702
702
|
- Keep short but meaningful
|
|
@@ -70,7 +70,7 @@ version-file = "tasktree_manager/_version.py"
|
|
|
70
70
|
# Semantic Release Configuration
|
|
71
71
|
[tool.semantic_release]
|
|
72
72
|
version_toml = ["pyproject.toml:tool.semantic_release.version"]
|
|
73
|
-
version = "2.
|
|
73
|
+
version = "2.1.0"
|
|
74
74
|
tag_format = "v{version}"
|
|
75
75
|
branch = "main"
|
|
76
76
|
commit_message = "chore(release): {version} [skip ci]"
|
|
@@ -125,6 +125,90 @@ def update_pyproject_version(current_version: str, new_version: str, dry_run: bo
|
|
|
125
125
|
return True
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
def get_changelog_from_commits() -> str:
|
|
129
|
+
"""Generate changelog content from conventional commits since the last tag.
|
|
130
|
+
|
|
131
|
+
Parses git log for conventional commit messages and groups them into
|
|
132
|
+
Added, Changed, and Fixed sections.
|
|
133
|
+
"""
|
|
134
|
+
# Find the last tag
|
|
135
|
+
try:
|
|
136
|
+
result = subprocess.run(
|
|
137
|
+
["git", "describe", "--tags", "--abbrev=0"],
|
|
138
|
+
capture_output=True, text=True, cwd=PROJECT_ROOT,
|
|
139
|
+
)
|
|
140
|
+
last_tag = result.stdout.strip() if result.returncode == 0 else ""
|
|
141
|
+
except Exception:
|
|
142
|
+
last_tag = ""
|
|
143
|
+
|
|
144
|
+
# Get commits since last tag (or all commits if no tag)
|
|
145
|
+
git_log_cmd = ["git", "log", "--pretty=format:%s"]
|
|
146
|
+
if last_tag:
|
|
147
|
+
git_log_cmd.append(f"{last_tag}..HEAD")
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
result = subprocess.run(
|
|
151
|
+
git_log_cmd, capture_output=True, text=True, cwd=PROJECT_ROOT,
|
|
152
|
+
)
|
|
153
|
+
commits = result.stdout.strip().splitlines() if result.returncode == 0 else []
|
|
154
|
+
except Exception:
|
|
155
|
+
commits = []
|
|
156
|
+
|
|
157
|
+
if not commits:
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
# Conventional commit type -> changelog section mapping
|
|
161
|
+
added_types = {"feat"}
|
|
162
|
+
fixed_types = {"fix"}
|
|
163
|
+
changed_types = {"refactor", "perf", "style", "build", "ci", "chore", "docs", "test"}
|
|
164
|
+
|
|
165
|
+
added = []
|
|
166
|
+
changed = []
|
|
167
|
+
fixed = []
|
|
168
|
+
|
|
169
|
+
# Pattern: type(scope): description or type!: description or type: description
|
|
170
|
+
commit_re = re.compile(r"^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$")
|
|
171
|
+
|
|
172
|
+
for commit in commits:
|
|
173
|
+
# Skip release commits
|
|
174
|
+
if commit.startswith("chore(release):"):
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
match = commit_re.match(commit)
|
|
178
|
+
if match:
|
|
179
|
+
ctype, scope, breaking, description = match.groups()
|
|
180
|
+
# Capitalize first letter of description
|
|
181
|
+
description = description[0].upper() + description[1:] if description else description
|
|
182
|
+
scope_prefix = f"**{scope}**: " if scope else ""
|
|
183
|
+
entry = f"- {scope_prefix}{description}"
|
|
184
|
+
|
|
185
|
+
if breaking:
|
|
186
|
+
entry = f"- **BREAKING**: {description}"
|
|
187
|
+
changed.append(entry)
|
|
188
|
+
elif ctype in added_types:
|
|
189
|
+
added.append(entry)
|
|
190
|
+
elif ctype in fixed_types:
|
|
191
|
+
fixed.append(entry)
|
|
192
|
+
elif ctype in changed_types:
|
|
193
|
+
changed.append(entry)
|
|
194
|
+
else:
|
|
195
|
+
changed.append(entry)
|
|
196
|
+
else:
|
|
197
|
+
# Non-conventional commit — include as-is under Changed
|
|
198
|
+
if commit.strip():
|
|
199
|
+
changed.append(f"- {commit}")
|
|
200
|
+
|
|
201
|
+
sections = []
|
|
202
|
+
if added:
|
|
203
|
+
sections.append("### Added\n" + "\n".join(added))
|
|
204
|
+
if changed:
|
|
205
|
+
sections.append("### Changed\n" + "\n".join(changed))
|
|
206
|
+
if fixed:
|
|
207
|
+
sections.append("### Fixed\n" + "\n".join(fixed))
|
|
208
|
+
|
|
209
|
+
return "\n\n".join(sections)
|
|
210
|
+
|
|
211
|
+
|
|
128
212
|
def update_changelog(new_version: str, message: str = "", dry_run: bool = False) -> bool:
|
|
129
213
|
"""Update CHANGELOG.md with a new version entry."""
|
|
130
214
|
if not CHANGELOG_PATH.exists():
|
|
@@ -142,7 +226,12 @@ def update_changelog(new_version: str, message: str = "", dry_run: bool = False)
|
|
|
142
226
|
if message:
|
|
143
227
|
new_entry = f"## [{new_version}] - {today}\n\n{message}\n\n"
|
|
144
228
|
else:
|
|
145
|
-
|
|
229
|
+
# Auto-generate from conventional commits
|
|
230
|
+
changelog_content = get_changelog_from_commits()
|
|
231
|
+
if changelog_content:
|
|
232
|
+
new_entry = f"## [{new_version}] - {today}\n\n{changelog_content}\n\n"
|
|
233
|
+
else:
|
|
234
|
+
new_entry = f"## [{new_version}] - {today}\n\nNo notable changes.\n\n"
|
|
146
235
|
|
|
147
236
|
if dry_run:
|
|
148
237
|
print(f"Would add new entry to CHANGELOG.md for version {new_version}")
|
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '2.
|
|
32
|
-
__version_tuple__ = version_tuple = (2,
|
|
31
|
+
__version__ = version = '2.1.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (2, 1, 0)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Main application for tasktree-manager."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import subprocess
|
|
4
5
|
|
|
5
6
|
from textual import work
|
|
@@ -8,6 +9,7 @@ from textual.binding import Binding
|
|
|
8
9
|
from textual.containers import Horizontal, Vertical
|
|
9
10
|
from textual.widgets import Footer, Header, Static
|
|
10
11
|
|
|
12
|
+
from .services.claude_hooks import ensure_claude_hooks
|
|
11
13
|
from .services.config import Config
|
|
12
14
|
from .services.git_ops import GitOps
|
|
13
15
|
from .services.models import GitStatus, Task, Worktree
|
|
@@ -141,7 +143,7 @@ class TaskTreeApp(App):
|
|
|
141
143
|
/* Status panel styling */
|
|
142
144
|
#status-display {
|
|
143
145
|
height: 100%;
|
|
144
|
-
padding: 0 1;
|
|
146
|
+
padding: 1 1 0 1;
|
|
145
147
|
background: $background;
|
|
146
148
|
color: $text;
|
|
147
149
|
overflow-y: auto;
|
|
@@ -250,6 +252,8 @@ class TaskTreeApp(App):
|
|
|
250
252
|
self._show_messages_panel: bool = False
|
|
251
253
|
# Used to preserve worktree selection during task list reload
|
|
252
254
|
self._preserved_worktree_name: str | None = None
|
|
255
|
+
# Claude session statuses: task_name -> status string
|
|
256
|
+
self._claude_statuses: dict[str, str] = {}
|
|
253
257
|
|
|
254
258
|
def _build_bindings_from_config(self) -> list[Binding]:
|
|
255
259
|
"""Build keybindings list from config.
|
|
@@ -306,6 +310,7 @@ class TaskTreeApp(App):
|
|
|
306
310
|
# Right column: Info/Status panel
|
|
307
311
|
with Vertical(id="right-column"):
|
|
308
312
|
with Vertical(id="status-panel"):
|
|
313
|
+
yield Static("Info", classes="panel-title", id="status-panel-title")
|
|
309
314
|
yield StatusPanel(id="status-display")
|
|
310
315
|
with Vertical(id="messages-panel"):
|
|
311
316
|
yield MessagesPanel(id="messages-display")
|
|
@@ -330,6 +335,9 @@ class TaskTreeApp(App):
|
|
|
330
335
|
task_list = self.query_one("#task-list", TaskList)
|
|
331
336
|
task_list.focus()
|
|
332
337
|
|
|
338
|
+
# Poll Claude session status files every 5 seconds
|
|
339
|
+
self.set_interval(5, self._poll_claude_statuses)
|
|
340
|
+
|
|
333
341
|
def watch_theme(self, theme: str) -> None:
|
|
334
342
|
"""Save theme to config when changed."""
|
|
335
343
|
if hasattr(self, "config") and self.config.theme != theme:
|
|
@@ -420,6 +428,27 @@ class TaskTreeApp(App):
|
|
|
420
428
|
finally:
|
|
421
429
|
task_list.loading = False
|
|
422
430
|
|
|
431
|
+
def _poll_claude_statuses(self) -> None:
|
|
432
|
+
"""Check .claude_status files and update task indicators."""
|
|
433
|
+
try:
|
|
434
|
+
task_list = self.query_one("#task-list", TaskList)
|
|
435
|
+
except Exception:
|
|
436
|
+
return
|
|
437
|
+
|
|
438
|
+
statuses: dict[str, str] = {}
|
|
439
|
+
for task in task_list.tasks:
|
|
440
|
+
status_file = task.path / ".claude_status"
|
|
441
|
+
if status_file.exists():
|
|
442
|
+
try:
|
|
443
|
+
data = json.loads(status_file.read_text())
|
|
444
|
+
statuses[task.name] = data.get("status", "unknown")
|
|
445
|
+
except (json.JSONDecodeError, OSError):
|
|
446
|
+
pass
|
|
447
|
+
|
|
448
|
+
if statuses != self._claude_statuses:
|
|
449
|
+
self._claude_statuses = statuses
|
|
450
|
+
task_list.refresh_claude_indicators(statuses)
|
|
451
|
+
|
|
423
452
|
def _refresh_current_task(self, preserve_selection: bool = False) -> None:
|
|
424
453
|
"""Refresh the current task's worktrees and status.
|
|
425
454
|
|
|
@@ -469,6 +498,26 @@ class TaskTreeApp(App):
|
|
|
469
498
|
self.notify(f"Failed to run {name}: {e}", severity="error")
|
|
470
499
|
return False
|
|
471
500
|
|
|
501
|
+
def on_descendant_focus(self, event) -> None:
|
|
502
|
+
"""Handle focus changes to update info panel mode."""
|
|
503
|
+
try:
|
|
504
|
+
status_panel = self.query_one("#status-display", StatusPanel)
|
|
505
|
+
except Exception:
|
|
506
|
+
return
|
|
507
|
+
|
|
508
|
+
if isinstance(event.widget, TaskList):
|
|
509
|
+
# Task list focused → show task changed files
|
|
510
|
+
if self.current_task:
|
|
511
|
+
status_panel.set_loading(True)
|
|
512
|
+
self._update_task_summary(self.current_task)
|
|
513
|
+
elif isinstance(event.widget, WorktreeList):
|
|
514
|
+
# Worktree list focused → show worktree details
|
|
515
|
+
if self.current_worktree and self.current_status:
|
|
516
|
+
status_panel.update_status(self.current_worktree, self.current_status)
|
|
517
|
+
elif self.current_worktree:
|
|
518
|
+
status_panel.set_loading(True)
|
|
519
|
+
self._update_worktree_status(self.current_worktree)
|
|
520
|
+
|
|
472
521
|
def on_task_list_task_highlighted(self, event: TaskList.TaskHighlighted) -> None:
|
|
473
522
|
"""Handle task highlight in task list."""
|
|
474
523
|
self.current_task = event.task
|
|
@@ -484,6 +533,12 @@ class TaskTreeApp(App):
|
|
|
484
533
|
preserved = self._preserved_worktree_name
|
|
485
534
|
self._preserved_worktree_name = None # Clear after use
|
|
486
535
|
worktree_list.load_worktrees(event.task.worktrees, preserve_selection=preserved)
|
|
536
|
+
|
|
537
|
+
# Show task summary if task list is focused
|
|
538
|
+
task_list = self.query_one("#task-list", TaskList)
|
|
539
|
+
if task_list.has_focus:
|
|
540
|
+
status_panel.set_loading(True)
|
|
541
|
+
self._update_task_summary(event.task)
|
|
487
542
|
else:
|
|
488
543
|
worktree_list.clear_worktrees()
|
|
489
544
|
status_panel.clear_status()
|
|
@@ -499,12 +554,19 @@ class TaskTreeApp(App):
|
|
|
499
554
|
# Widget not mounted yet during app startup
|
|
500
555
|
return
|
|
501
556
|
|
|
557
|
+
# Only update status panel if worktree list is focused
|
|
558
|
+
worktree_list = self.query_one("#worktree-list", WorktreeList)
|
|
502
559
|
if event.worktree:
|
|
503
|
-
|
|
504
|
-
|
|
560
|
+
if worktree_list.has_focus:
|
|
561
|
+
status_panel.set_loading(True)
|
|
562
|
+
self._update_worktree_status(event.worktree)
|
|
563
|
+
else:
|
|
564
|
+
# Still fetch status in background for when user switches focus
|
|
565
|
+
self._update_worktree_status(event.worktree)
|
|
505
566
|
else:
|
|
506
567
|
self.current_status = None
|
|
507
|
-
|
|
568
|
+
if worktree_list.has_focus:
|
|
569
|
+
status_panel.clear_status()
|
|
508
570
|
|
|
509
571
|
@work(thread=True, exclusive=True, group="status_update")
|
|
510
572
|
def _update_worktree_status(self, worktree: Worktree) -> None:
|
|
@@ -520,7 +582,31 @@ class TaskTreeApp(App):
|
|
|
520
582
|
self.current_status = status
|
|
521
583
|
try:
|
|
522
584
|
status_panel = self.query_one("#status-display", StatusPanel)
|
|
523
|
-
|
|
585
|
+
# Only show worktree status if worktree list is focused
|
|
586
|
+
worktree_list = self.query_one("#worktree-list", WorktreeList)
|
|
587
|
+
if worktree_list.has_focus:
|
|
588
|
+
status_panel.update_status(worktree, status)
|
|
589
|
+
except Exception:
|
|
590
|
+
pass
|
|
591
|
+
|
|
592
|
+
@work(thread=True, exclusive=True, group="task_summary_update")
|
|
593
|
+
def _update_task_summary(self, task: Task) -> None:
|
|
594
|
+
"""Fetch git status for all dirty worktrees in background thread."""
|
|
595
|
+
statuses: dict[str, GitStatus] = {}
|
|
596
|
+
for wt in task.worktrees:
|
|
597
|
+
if wt.is_dirty:
|
|
598
|
+
statuses[wt.name] = GitOps.get_status(wt)
|
|
599
|
+
self.call_from_thread(self._apply_task_summary, task, statuses)
|
|
600
|
+
|
|
601
|
+
def _apply_task_summary(self, task: Task, statuses: dict[str, GitStatus]) -> None:
|
|
602
|
+
"""Apply fetched task summary to the UI (called on main thread)."""
|
|
603
|
+
# Only update if this task is still selected and task list is focused
|
|
604
|
+
if self.current_task and self.current_task.name == task.name:
|
|
605
|
+
try:
|
|
606
|
+
status_panel = self.query_one("#status-display", StatusPanel)
|
|
607
|
+
task_list = self.query_one("#task-list", TaskList)
|
|
608
|
+
if task_list.has_focus:
|
|
609
|
+
status_panel.update_task_summary(task, statuses)
|
|
524
610
|
except Exception:
|
|
525
611
|
pass
|
|
526
612
|
|
|
@@ -918,7 +1004,7 @@ class TaskTreeApp(App):
|
|
|
918
1004
|
self.query_one("#worktree-list", WorktreeList).focus()
|
|
919
1005
|
|
|
920
1006
|
def action_open_claude_resume(self) -> None:
|
|
921
|
-
"""Open Claude Code
|
|
1007
|
+
"""Open Claude Code in a new Ghostty tab (resume session)."""
|
|
922
1008
|
if not self.current_task:
|
|
923
1009
|
self.notify("No task selected", severity="warning")
|
|
924
1010
|
return
|
|
@@ -928,30 +1014,17 @@ class TaskTreeApp(App):
|
|
|
928
1014
|
self.notify("Task directory not found", severity="error")
|
|
929
1015
|
return
|
|
930
1016
|
|
|
931
|
-
saved_task_name = self.current_task.name
|
|
932
|
-
saved_worktree_name = self.current_worktree.name if self.current_worktree else None
|
|
933
|
-
|
|
934
1017
|
# Fetch fresh task data to ensure worktrees are up-to-date
|
|
935
|
-
fresh_task = self.task_manager.get_task(
|
|
1018
|
+
fresh_task = self.task_manager.get_task(self.current_task.name)
|
|
936
1019
|
if fresh_task:
|
|
937
|
-
# Create claude.md files if they don't exist
|
|
938
1020
|
self.task_manager.ensure_claude_md_files(fresh_task)
|
|
939
1021
|
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
self._run_external_command(
|
|
944
|
-
[self.config.claude_path, "-r"],
|
|
945
|
-
cwd=task_path,
|
|
946
|
-
name="Claude Code",
|
|
947
|
-
install_hint="npm install -g @anthropic-ai/claude-code",
|
|
948
|
-
)
|
|
949
|
-
|
|
950
|
-
self._load_tasks_with_selection(saved_task_name, saved_worktree_name)
|
|
951
|
-
self.query_one("#task-list", TaskList).focus()
|
|
1022
|
+
ensure_claude_hooks(task_path)
|
|
1023
|
+
self._open_ghostty_tab(task_path, command=f"{self.config.claude_path} -r")
|
|
1024
|
+
self.notify("Opened Claude Code in new tab (resume)")
|
|
952
1025
|
|
|
953
1026
|
def action_open_claude_new(self) -> None:
|
|
954
|
-
"""Open a new Claude Code session in
|
|
1027
|
+
"""Open a new Claude Code session in a new Ghostty tab."""
|
|
955
1028
|
if not self.current_task:
|
|
956
1029
|
self.notify("No task selected", severity="warning")
|
|
957
1030
|
return
|
|
@@ -961,27 +1034,14 @@ class TaskTreeApp(App):
|
|
|
961
1034
|
self.notify("Task directory not found", severity="error")
|
|
962
1035
|
return
|
|
963
1036
|
|
|
964
|
-
saved_task_name = self.current_task.name
|
|
965
|
-
saved_worktree_name = self.current_worktree.name if self.current_worktree else None
|
|
966
|
-
|
|
967
1037
|
# Fetch fresh task data to ensure worktrees are up-to-date
|
|
968
|
-
fresh_task = self.task_manager.get_task(
|
|
1038
|
+
fresh_task = self.task_manager.get_task(self.current_task.name)
|
|
969
1039
|
if fresh_task:
|
|
970
|
-
# Create claude.md files if they don't exist
|
|
971
1040
|
self.task_manager.ensure_claude_md_files(fresh_task)
|
|
972
1041
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
self._run_external_command(
|
|
977
|
-
[self.config.claude_path],
|
|
978
|
-
cwd=task_path,
|
|
979
|
-
name="Claude Code",
|
|
980
|
-
install_hint="npm install -g @anthropic-ai/claude-code",
|
|
981
|
-
)
|
|
982
|
-
|
|
983
|
-
self._load_tasks_with_selection(saved_task_name, saved_worktree_name)
|
|
984
|
-
self.query_one("#task-list", TaskList).focus()
|
|
1042
|
+
ensure_claude_hooks(task_path)
|
|
1043
|
+
self._open_ghostty_tab(task_path, command=self.config.claude_path)
|
|
1044
|
+
self.notify("Opened new Claude Code session in new tab")
|
|
985
1045
|
|
|
986
1046
|
def action_open_folder(self) -> None:
|
|
987
1047
|
"""Open current folder in a new terminal tab."""
|
|
@@ -1006,15 +1066,23 @@ class TaskTreeApp(App):
|
|
|
1006
1066
|
# Open in new terminal tab
|
|
1007
1067
|
self._open_ghostty_tab(folder_path)
|
|
1008
1068
|
|
|
1009
|
-
def _open_ghostty_tab(self, path) -> None:
|
|
1010
|
-
"""Open a new terminal tab at the given path.
|
|
1069
|
+
def _open_ghostty_tab(self, path, command: str | None = None) -> None:
|
|
1070
|
+
"""Open a new terminal tab at the given path, optionally running a command.
|
|
1011
1071
|
|
|
1012
1072
|
NOTE: This implementation is specific to the Ghostty terminal emulator
|
|
1013
1073
|
on macOS, using AppleScript. It will silently fail on other terminals.
|
|
1074
|
+
|
|
1075
|
+
Args:
|
|
1076
|
+
path: Directory to cd into in the new tab
|
|
1077
|
+
command: Optional command to run after cd (e.g., "claude -r")
|
|
1014
1078
|
"""
|
|
1015
1079
|
# Ghostty: Use AppleScript to activate and open new tab, then cd
|
|
1016
1080
|
# Escape single quotes for AppleScript string interpolation
|
|
1017
1081
|
safe_path = str(path).replace("'", "'\\''")
|
|
1082
|
+
if command:
|
|
1083
|
+
shell_cmd = f"cd '{safe_path}' && clear && {command}"
|
|
1084
|
+
else:
|
|
1085
|
+
shell_cmd = f"cd '{safe_path}' && clear"
|
|
1018
1086
|
script = f"""
|
|
1019
1087
|
tell application "Ghostty"
|
|
1020
1088
|
activate
|
|
@@ -1023,7 +1091,7 @@ class TaskTreeApp(App):
|
|
|
1023
1091
|
tell process "Ghostty"
|
|
1024
1092
|
keystroke "t" using command down
|
|
1025
1093
|
delay 0.1
|
|
1026
|
-
keystroke "
|
|
1094
|
+
keystroke "{shell_cmd}"
|
|
1027
1095
|
key code 36
|
|
1028
1096
|
end tell
|
|
1029
1097
|
end tell
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Claude Code hook configuration for status monitoring."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _make_hook(status: str, status_file: str) -> dict:
|
|
8
|
+
"""Create a single hook entry that writes status to the given file path."""
|
|
9
|
+
safe_path = status_file.replace("'", "'\\''")
|
|
10
|
+
return {
|
|
11
|
+
"type": "command",
|
|
12
|
+
"command": f"printf '{{\"status\":\"{status}\",\"ts\":%d}}' $(date +%s) > '{safe_path}'",
|
|
13
|
+
"async": True,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _build_hooks_config(task_path: Path) -> dict:
|
|
18
|
+
"""Build hooks config with absolute path to status file."""
|
|
19
|
+
status_file = str(task_path / ".claude_status")
|
|
20
|
+
return {
|
|
21
|
+
"SessionStart": [{"hooks": [_make_hook("running", status_file)]}],
|
|
22
|
+
"UserPromptSubmit": [{"hooks": [_make_hook("running", status_file)]}],
|
|
23
|
+
"Stop": [{"hooks": [_make_hook("waiting", status_file)]}],
|
|
24
|
+
"SessionEnd": [{"hooks": [_make_hook("ended", status_file)]}],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ensure_claude_hooks(task_path: Path) -> None:
|
|
29
|
+
"""Create .claude/settings.local.json with status-reporting hooks.
|
|
30
|
+
|
|
31
|
+
Merges with existing settings if the file already exists, preserving
|
|
32
|
+
user's own configuration while adding/updating the status hooks.
|
|
33
|
+
"""
|
|
34
|
+
claude_dir = task_path / ".claude"
|
|
35
|
+
claude_dir.mkdir(exist_ok=True)
|
|
36
|
+
|
|
37
|
+
settings_file = claude_dir / "settings.local.json"
|
|
38
|
+
|
|
39
|
+
if settings_file.exists():
|
|
40
|
+
try:
|
|
41
|
+
existing = json.loads(settings_file.read_text())
|
|
42
|
+
except (json.JSONDecodeError, OSError):
|
|
43
|
+
existing = {}
|
|
44
|
+
else:
|
|
45
|
+
existing = {}
|
|
46
|
+
|
|
47
|
+
# Merge hooks into existing settings
|
|
48
|
+
existing["hooks"] = _build_hooks_config(task_path)
|
|
49
|
+
|
|
50
|
+
settings_file.write_text(json.dumps(existing, indent=2) + "\n")
|
|
@@ -3,19 +3,23 @@
|
|
|
3
3
|
from rich.text import Text
|
|
4
4
|
from textual.widgets import Static
|
|
5
5
|
|
|
6
|
-
from ..services.models import GitStatus, Worktree
|
|
6
|
+
from ..services.models import GitStatus, Task, Worktree
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class StatusPanel(Static):
|
|
10
|
-
"""Panel displaying git status for selected worktree."""
|
|
10
|
+
"""Panel displaying git status for selected worktree or task summary."""
|
|
11
11
|
|
|
12
12
|
def __init__(self, *args, **kwargs):
|
|
13
13
|
super().__init__(*args, **kwargs)
|
|
14
14
|
self._worktree_name: str = ""
|
|
15
15
|
self._status: GitStatus | None = None
|
|
16
|
+
self._current_task: Task | None = None
|
|
17
|
+
self._task_statuses: dict[str, GitStatus] = {}
|
|
18
|
+
self._mode: str = "worktree" # "worktree" or "task"
|
|
16
19
|
|
|
17
20
|
def update_status(self, worktree: Worktree | None, status: GitStatus | None) -> None:
|
|
18
21
|
"""Update the status display for a worktree."""
|
|
22
|
+
self._mode = "worktree"
|
|
19
23
|
if worktree is None or status is None:
|
|
20
24
|
self._worktree_name = ""
|
|
21
25
|
self._status = None
|
|
@@ -26,8 +30,69 @@ class StatusPanel(Static):
|
|
|
26
30
|
self._status = status
|
|
27
31
|
self._update_display()
|
|
28
32
|
|
|
33
|
+
def update_task_summary(
|
|
34
|
+
self, task: Task | None, statuses: dict[str, GitStatus] | None = None
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Update the display with a task summary.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
task: The task to display
|
|
40
|
+
statuses: Optional dict of worktree_name -> GitStatus with file details
|
|
41
|
+
"""
|
|
42
|
+
self._mode = "task"
|
|
43
|
+
self._current_task = task
|
|
44
|
+
self._task_statuses = statuses or {}
|
|
45
|
+
self._update_display()
|
|
46
|
+
|
|
29
47
|
def _update_display(self) -> None:
|
|
30
48
|
"""Update the display content."""
|
|
49
|
+
if self._mode == "task":
|
|
50
|
+
self._render_task_summary()
|
|
51
|
+
else:
|
|
52
|
+
self._render_worktree_status()
|
|
53
|
+
|
|
54
|
+
def _render_task_summary(self) -> None:
|
|
55
|
+
"""Render the task summary view — changed files grouped by repo."""
|
|
56
|
+
if self._current_task is None:
|
|
57
|
+
self.update(Text("No task selected", style="dim"))
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
task = self._current_task
|
|
61
|
+
text = Text()
|
|
62
|
+
|
|
63
|
+
dirty_worktrees = [wt for wt in task.worktrees if wt.is_dirty]
|
|
64
|
+
|
|
65
|
+
if not dirty_worktrees:
|
|
66
|
+
text.append("All repos clean", style="green")
|
|
67
|
+
self.update(text)
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
for wt in dirty_worktrees:
|
|
71
|
+
# Repo header
|
|
72
|
+
text.append(f"{wt.name}", style="bold")
|
|
73
|
+
text.append("\n")
|
|
74
|
+
|
|
75
|
+
status = self._task_statuses.get(wt.name)
|
|
76
|
+
if status:
|
|
77
|
+
for status_code, filename in status.all_changes:
|
|
78
|
+
if status_code.strip().startswith("?"):
|
|
79
|
+
text.append(f" {status_code} ", style="red")
|
|
80
|
+
text.append(f"{filename}\n", style="red")
|
|
81
|
+
elif status_code.strip().startswith("M") or status_code.strip() == "M":
|
|
82
|
+
text.append(f" {status_code} ", style="yellow")
|
|
83
|
+
text.append(f"{filename}\n")
|
|
84
|
+
else:
|
|
85
|
+
text.append(f" {status_code} ", style="green")
|
|
86
|
+
text.append(f"{filename}\n")
|
|
87
|
+
else:
|
|
88
|
+
text.append(f" {wt.changed_files} files changed\n", style="dim italic")
|
|
89
|
+
|
|
90
|
+
text.append("\n")
|
|
91
|
+
|
|
92
|
+
self.update(text)
|
|
93
|
+
|
|
94
|
+
def _render_worktree_status(self) -> None:
|
|
95
|
+
"""Render the worktree-specific status view."""
|
|
31
96
|
if not self._worktree_name or self._status is None:
|
|
32
97
|
self.update(Text("No worktree selected", style="dim"))
|
|
33
98
|
return
|
|
@@ -81,6 +146,8 @@ class StatusPanel(Static):
|
|
|
81
146
|
"""Clear the status display."""
|
|
82
147
|
self._worktree_name = ""
|
|
83
148
|
self._status = None
|
|
149
|
+
self._current_task = None
|
|
150
|
+
self._mode = "worktree"
|
|
84
151
|
self.update(Text("No worktree selected", style="dim"))
|
|
85
152
|
|
|
86
153
|
def set_loading(self, loading: bool = True) -> None:
|
|
@@ -60,27 +60,42 @@ class TaskList(OptionList):
|
|
|
60
60
|
"""Compatibility property - sets highlighted index."""
|
|
61
61
|
self.highlighted = value
|
|
62
62
|
|
|
63
|
-
def _format_task_option(self, task: Task) -> Option:
|
|
63
|
+
def _format_task_option(self, task: Task, claude_status: str | None = None) -> Option:
|
|
64
64
|
"""Format a task as an Option for display."""
|
|
65
|
-
|
|
65
|
+
claude_md = "[blue]◆[/]" if task.has_claude_md else " "
|
|
66
|
+
if claude_status == "running":
|
|
67
|
+
hook_indicator = " [magenta]⟳[/]"
|
|
68
|
+
elif claude_status == "waiting":
|
|
69
|
+
hook_indicator = " [yellow]![/]"
|
|
70
|
+
elif claude_status == "ended":
|
|
71
|
+
hook_indicator = " [green]✓[/]"
|
|
72
|
+
else:
|
|
73
|
+
hook_indicator = ""
|
|
66
74
|
if task.is_dirty:
|
|
67
|
-
prompt = f"[red]●[/]{
|
|
75
|
+
prompt = f"[red]●[/]{claude_md}{task.name} [red]({task.dirty_count})[/]{hook_indicator}"
|
|
68
76
|
else:
|
|
69
|
-
prompt = f" {
|
|
77
|
+
prompt = f" {claude_md}{task.name}{hook_indicator}"
|
|
70
78
|
return Option(prompt, id=task.name)
|
|
71
79
|
|
|
72
|
-
def load_tasks(
|
|
80
|
+
def load_tasks(
|
|
81
|
+
self,
|
|
82
|
+
tasks: list[Task],
|
|
83
|
+
preserve_selection: str | None = None,
|
|
84
|
+
claude_statuses: dict[str, str] | None = None,
|
|
85
|
+
) -> None:
|
|
73
86
|
"""Load tasks into the list.
|
|
74
87
|
|
|
75
88
|
Args:
|
|
76
89
|
tasks: List of tasks to load
|
|
77
90
|
preserve_selection: Optional task name to preserve selection for
|
|
91
|
+
claude_statuses: Optional dict of task_name -> claude status string
|
|
78
92
|
"""
|
|
79
93
|
sorted_tasks = self._sort_tasks(tasks)
|
|
80
94
|
self.tasks = sorted_tasks
|
|
81
95
|
self.clear_options()
|
|
96
|
+
statuses = claude_statuses or {}
|
|
82
97
|
for task in sorted_tasks:
|
|
83
|
-
self.add_option(self._format_task_option(task))
|
|
98
|
+
self.add_option(self._format_task_option(task, statuses.get(task.name)))
|
|
84
99
|
|
|
85
100
|
# Select item - preserve previous selection if specified
|
|
86
101
|
if self.tasks and self.option_count > 0:
|
|
@@ -131,6 +146,15 @@ class TaskList(OptionList):
|
|
|
131
146
|
self.highlighted = current_index
|
|
132
147
|
self._emit_highlighted()
|
|
133
148
|
|
|
149
|
+
def refresh_claude_indicators(self, statuses: dict[str, str]) -> None:
|
|
150
|
+
"""Update Claude status indicators without full reload.
|
|
151
|
+
|
|
152
|
+
Re-renders all option labels with updated claude status indicators.
|
|
153
|
+
"""
|
|
154
|
+
for i, task in enumerate(self.tasks):
|
|
155
|
+
new_option = self._format_task_option(task, statuses.get(task.name))
|
|
156
|
+
self.replace_option_prompt_at_index(i, new_option.prompt)
|
|
157
|
+
|
|
134
158
|
def cycle_sort_mode(self) -> None:
|
|
135
159
|
"""Cycle through sort modes and reload tasks."""
|
|
136
160
|
modes = list(SortMode)
|
|
@@ -140,9 +140,9 @@ class WorktreeList(OptionList):
|
|
|
140
140
|
claude_indicator = "[blue]◆[/]" if worktree.has_claude_md else " "
|
|
141
141
|
|
|
142
142
|
if worktree.is_dirty:
|
|
143
|
-
prompt = f" {claude_indicator}{name_col}
|
|
143
|
+
prompt = f" {claude_indicator}{name_col} {branch_col} [red]✗ {worktree.changed_files} files[/]"
|
|
144
144
|
else:
|
|
145
|
-
prompt = f" {claude_indicator}{name_col}
|
|
145
|
+
prompt = f" {claude_indicator}{name_col} {branch_col} [green]✓[/]"
|
|
146
146
|
self.add_option(Option(prompt, id=worktree.name))
|
|
147
147
|
|
|
148
148
|
def _emit_highlighted(self) -> None:
|
|
File without changes
|
{tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/assets/README.md
RENAMED
|
File without changes
|
{tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/assets/dashboard_app.py
RENAMED
|
File without changes
|
{tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/assets/data_viewer.py
RENAMED
|
File without changes
|
{tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/assets/todo_app.py
RENAMED
|
File without changes
|
{tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/assets/worker_demo.py
RENAMED
|
File without changes
|
{tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/references/layouts.md
RENAMED
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/references/styling.md
RENAMED
|
File without changes
|
{tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/.claude/skills/textual-tui/references/widgets.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tasktree_manager-2.0.0 → tasktree_manager-2.1.0}/tasktree_manager/widgets/messages_panel.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|