cloudscope 0.1.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.
- cloudscope/__init__.py +3 -0
- cloudscope/__main__.py +12 -0
- cloudscope/app.py +100 -0
- cloudscope/auth/__init__.py +0 -0
- cloudscope/auth/aws.py +42 -0
- cloudscope/auth/drive_oauth.py +77 -0
- cloudscope/auth/gcp.py +42 -0
- cloudscope/backends/__init__.py +0 -0
- cloudscope/backends/base.py +98 -0
- cloudscope/backends/drive.py +568 -0
- cloudscope/backends/gcs.py +270 -0
- cloudscope/backends/registry.py +23 -0
- cloudscope/backends/s3.py +281 -0
- cloudscope/config.py +70 -0
- cloudscope/models/__init__.py +0 -0
- cloudscope/models/cloud_file.py +48 -0
- cloudscope/models/sync_state.py +87 -0
- cloudscope/models/transfer.py +46 -0
- cloudscope/sync/__init__.py +0 -0
- cloudscope/sync/differ.py +165 -0
- cloudscope/sync/engine.py +214 -0
- cloudscope/sync/plan.py +46 -0
- cloudscope/sync/resolver.py +64 -0
- cloudscope/sync/state.py +140 -0
- cloudscope/transfer/__init__.py +0 -0
- cloudscope/transfer/manager.py +150 -0
- cloudscope/transfer/progress.py +20 -0
- cloudscope/tui/__init__.py +0 -0
- cloudscope/tui/commands.py +47 -0
- cloudscope/tui/modals/__init__.py +0 -0
- cloudscope/tui/modals/confirm_dialog.py +93 -0
- cloudscope/tui/modals/download_dialog.py +111 -0
- cloudscope/tui/modals/new_folder.py +96 -0
- cloudscope/tui/modals/sync_dialog.py +142 -0
- cloudscope/tui/modals/upload_dialog.py +109 -0
- cloudscope/tui/screens/__init__.py +0 -0
- cloudscope/tui/screens/auth_setup.py +154 -0
- cloudscope/tui/screens/browse.py +282 -0
- cloudscope/tui/screens/settings.py +222 -0
- cloudscope/tui/screens/sync_config.py +245 -0
- cloudscope/tui/styles/cloudscope.tcss +336 -0
- cloudscope/tui/widgets/__init__.py +0 -0
- cloudscope/tui/widgets/app_footer.py +46 -0
- cloudscope/tui/widgets/breadcrumb.py +39 -0
- cloudscope/tui/widgets/cloud_tree.py +146 -0
- cloudscope/tui/widgets/file_table.py +113 -0
- cloudscope/tui/widgets/preview_panel.py +59 -0
- cloudscope/tui/widgets/status_bar.py +27 -0
- cloudscope/tui/widgets/transfer_panel.py +54 -0
- cloudscope-0.1.0.dist-info/METADATA +22 -0
- cloudscope-0.1.0.dist-info/RECORD +53 -0
- cloudscope-0.1.0.dist-info/WHEEL +4 -0
- cloudscope-0.1.0.dist-info/entry_points.txt +2 -0
cloudscope/sync/state.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""SQLite-backed sync state tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from platformdirs import user_data_dir
|
|
10
|
+
|
|
11
|
+
from cloudscope.models.sync_state import SyncRecord
|
|
12
|
+
|
|
13
|
+
SYNC_DATA_DIR = Path(user_data_dir("cloudscope", ensure_exists=True)) / "sync"
|
|
14
|
+
|
|
15
|
+
_SCHEMA = """
|
|
16
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
17
|
+
relative_path TEXT PRIMARY KEY,
|
|
18
|
+
local_size INTEGER,
|
|
19
|
+
local_mtime REAL,
|
|
20
|
+
local_checksum TEXT,
|
|
21
|
+
remote_size INTEGER,
|
|
22
|
+
remote_mtime REAL,
|
|
23
|
+
remote_checksum TEXT,
|
|
24
|
+
last_synced REAL NOT NULL,
|
|
25
|
+
sync_direction TEXT NOT NULL DEFAULT 'both'
|
|
26
|
+
);
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SyncStateDB:
|
|
31
|
+
"""Persistent sync state stored in a SQLite database."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, db_path: Path | None = None) -> None:
|
|
34
|
+
if db_path is None:
|
|
35
|
+
SYNC_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
db_path = SYNC_DATA_DIR / "sync_state.db"
|
|
37
|
+
self._db_path = db_path
|
|
38
|
+
self._conn: sqlite3.Connection | None = None
|
|
39
|
+
|
|
40
|
+
def open(self) -> None:
|
|
41
|
+
self._conn = sqlite3.connect(str(self._db_path))
|
|
42
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
43
|
+
self._conn.execute(_SCHEMA)
|
|
44
|
+
self._conn.commit()
|
|
45
|
+
|
|
46
|
+
def close(self) -> None:
|
|
47
|
+
if self._conn:
|
|
48
|
+
self._conn.close()
|
|
49
|
+
self._conn = None
|
|
50
|
+
|
|
51
|
+
def __enter__(self) -> SyncStateDB:
|
|
52
|
+
self.open()
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def __exit__(self, *args: object) -> None:
|
|
56
|
+
self.close()
|
|
57
|
+
|
|
58
|
+
def _ensure_open(self) -> sqlite3.Connection:
|
|
59
|
+
if self._conn is None:
|
|
60
|
+
self.open()
|
|
61
|
+
return self._conn # type: ignore[return-value]
|
|
62
|
+
|
|
63
|
+
def get_record(self, relative_path: str) -> SyncRecord | None:
|
|
64
|
+
conn = self._ensure_open()
|
|
65
|
+
row = conn.execute(
|
|
66
|
+
"SELECT * FROM sync_state WHERE relative_path = ?", (relative_path,)
|
|
67
|
+
).fetchone()
|
|
68
|
+
if row is None:
|
|
69
|
+
return None
|
|
70
|
+
return self._row_to_record(row)
|
|
71
|
+
|
|
72
|
+
def upsert_record(self, record: SyncRecord) -> None:
|
|
73
|
+
conn = self._ensure_open()
|
|
74
|
+
conn.execute(
|
|
75
|
+
"""
|
|
76
|
+
INSERT INTO sync_state
|
|
77
|
+
(relative_path, local_size, local_mtime, local_checksum,
|
|
78
|
+
remote_size, remote_mtime, remote_checksum, last_synced, sync_direction)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
80
|
+
ON CONFLICT(relative_path) DO UPDATE SET
|
|
81
|
+
local_size = excluded.local_size,
|
|
82
|
+
local_mtime = excluded.local_mtime,
|
|
83
|
+
local_checksum = excluded.local_checksum,
|
|
84
|
+
remote_size = excluded.remote_size,
|
|
85
|
+
remote_mtime = excluded.remote_mtime,
|
|
86
|
+
remote_checksum = excluded.remote_checksum,
|
|
87
|
+
last_synced = excluded.last_synced,
|
|
88
|
+
sync_direction = excluded.sync_direction
|
|
89
|
+
""",
|
|
90
|
+
(
|
|
91
|
+
record.relative_path,
|
|
92
|
+
record.local_size,
|
|
93
|
+
record.local_mtime,
|
|
94
|
+
record.local_checksum,
|
|
95
|
+
record.remote_size,
|
|
96
|
+
record.remote_mtime,
|
|
97
|
+
record.remote_checksum,
|
|
98
|
+
record.last_synced,
|
|
99
|
+
record.sync_direction,
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
conn.commit()
|
|
103
|
+
|
|
104
|
+
def delete_record(self, relative_path: str) -> None:
|
|
105
|
+
conn = self._ensure_open()
|
|
106
|
+
conn.execute(
|
|
107
|
+
"DELETE FROM sync_state WHERE relative_path = ?", (relative_path,)
|
|
108
|
+
)
|
|
109
|
+
conn.commit()
|
|
110
|
+
|
|
111
|
+
def all_records(self) -> list[SyncRecord]:
|
|
112
|
+
conn = self._ensure_open()
|
|
113
|
+
rows = conn.execute("SELECT * FROM sync_state ORDER BY relative_path").fetchall()
|
|
114
|
+
return [self._row_to_record(row) for row in rows]
|
|
115
|
+
|
|
116
|
+
def clear(self) -> None:
|
|
117
|
+
conn = self._ensure_open()
|
|
118
|
+
conn.execute("DELETE FROM sync_state")
|
|
119
|
+
conn.commit()
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _row_to_record(row: tuple) -> SyncRecord:
|
|
123
|
+
return SyncRecord(
|
|
124
|
+
relative_path=row[0],
|
|
125
|
+
local_size=row[1],
|
|
126
|
+
local_mtime=row[2],
|
|
127
|
+
local_checksum=row[3],
|
|
128
|
+
remote_size=row[4],
|
|
129
|
+
remote_mtime=row[5],
|
|
130
|
+
remote_checksum=row[6],
|
|
131
|
+
last_synced=row[7],
|
|
132
|
+
sync_direction=row[8],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def db_path_for_sync_pair(backend_type: str, container: str, prefix: str) -> Path:
|
|
137
|
+
"""Generate a unique DB path for a sync pair."""
|
|
138
|
+
safe_name = f"{backend_type}_{container}_{prefix}".replace("/", "_").replace("\\", "_")
|
|
139
|
+
SYNC_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
return SYNC_DATA_DIR / f"{safe_name}.db"
|
|
File without changes
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Transfer manager — queues and executes file transfers with concurrency control."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from cloudscope.backends.base import CloudBackend
|
|
11
|
+
from cloudscope.models.transfer import TransferDirection, TransferJob, TransferStatus
|
|
12
|
+
from cloudscope.transfer.progress import ProgressAdapter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TransferManager:
|
|
16
|
+
"""Manages concurrent file transfer operations."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, max_concurrent: int = 3) -> None:
|
|
19
|
+
self._semaphore = asyncio.Semaphore(max_concurrent)
|
|
20
|
+
self._jobs: dict[str, TransferJob] = {}
|
|
21
|
+
self._on_update: Callable[[TransferJob], None] | None = None
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def jobs(self) -> list[TransferJob]:
|
|
25
|
+
return list(self._jobs.values())
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def active_jobs(self) -> list[TransferJob]:
|
|
29
|
+
return [
|
|
30
|
+
j
|
|
31
|
+
for j in self._jobs.values()
|
|
32
|
+
if j.status in (TransferStatus.PENDING, TransferStatus.IN_PROGRESS)
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def set_update_callback(self, callback: Callable[[TransferJob], None]) -> None:
|
|
36
|
+
self._on_update = callback
|
|
37
|
+
|
|
38
|
+
async def download(
|
|
39
|
+
self,
|
|
40
|
+
backend: CloudBackend,
|
|
41
|
+
container: str,
|
|
42
|
+
remote_path: str,
|
|
43
|
+
local_path: str,
|
|
44
|
+
) -> TransferJob:
|
|
45
|
+
"""Queue a download job."""
|
|
46
|
+
job = TransferJob(
|
|
47
|
+
id=str(uuid.uuid4())[:8],
|
|
48
|
+
direction=TransferDirection.DOWNLOAD,
|
|
49
|
+
local_path=Path(local_path),
|
|
50
|
+
remote_container=container,
|
|
51
|
+
remote_path=remote_path,
|
|
52
|
+
backend_type=backend.backend_type,
|
|
53
|
+
)
|
|
54
|
+
self._jobs[job.id] = job
|
|
55
|
+
self._notify(job)
|
|
56
|
+
|
|
57
|
+
asyncio.create_task(self._execute_download(backend, job))
|
|
58
|
+
return job
|
|
59
|
+
|
|
60
|
+
async def upload(
|
|
61
|
+
self,
|
|
62
|
+
backend: CloudBackend,
|
|
63
|
+
container: str,
|
|
64
|
+
local_path: str,
|
|
65
|
+
remote_path: str,
|
|
66
|
+
) -> TransferJob:
|
|
67
|
+
"""Queue an upload job."""
|
|
68
|
+
local = Path(local_path)
|
|
69
|
+
job = TransferJob(
|
|
70
|
+
id=str(uuid.uuid4())[:8],
|
|
71
|
+
direction=TransferDirection.UPLOAD,
|
|
72
|
+
local_path=local,
|
|
73
|
+
remote_container=container,
|
|
74
|
+
remote_path=remote_path,
|
|
75
|
+
backend_type=backend.backend_type,
|
|
76
|
+
total_bytes=local.stat().st_size if local.exists() else 0,
|
|
77
|
+
)
|
|
78
|
+
self._jobs[job.id] = job
|
|
79
|
+
self._notify(job)
|
|
80
|
+
|
|
81
|
+
asyncio.create_task(self._execute_upload(backend, job))
|
|
82
|
+
return job
|
|
83
|
+
|
|
84
|
+
async def _execute_download(
|
|
85
|
+
self, backend: CloudBackend, job: TransferJob
|
|
86
|
+
) -> None:
|
|
87
|
+
async with self._semaphore:
|
|
88
|
+
job.status = TransferStatus.IN_PROGRESS
|
|
89
|
+
self._notify(job)
|
|
90
|
+
try:
|
|
91
|
+
# Get file size first
|
|
92
|
+
try:
|
|
93
|
+
stat = await backend.stat(job.remote_container, job.remote_path)
|
|
94
|
+
job.total_bytes = stat.size
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
progress = ProgressAdapter(job, self._notify)
|
|
99
|
+
await backend.download(
|
|
100
|
+
job.remote_container,
|
|
101
|
+
job.remote_path,
|
|
102
|
+
str(job.local_path),
|
|
103
|
+
progress_callback=progress,
|
|
104
|
+
)
|
|
105
|
+
job.status = TransferStatus.COMPLETED
|
|
106
|
+
job.transferred_bytes = job.total_bytes
|
|
107
|
+
except Exception as e:
|
|
108
|
+
job.status = TransferStatus.FAILED
|
|
109
|
+
job.error = str(e)
|
|
110
|
+
self._notify(job)
|
|
111
|
+
|
|
112
|
+
async def _execute_upload(
|
|
113
|
+
self, backend: CloudBackend, job: TransferJob
|
|
114
|
+
) -> None:
|
|
115
|
+
async with self._semaphore:
|
|
116
|
+
job.status = TransferStatus.IN_PROGRESS
|
|
117
|
+
self._notify(job)
|
|
118
|
+
try:
|
|
119
|
+
progress = ProgressAdapter(job, self._notify)
|
|
120
|
+
await backend.upload(
|
|
121
|
+
job.remote_container,
|
|
122
|
+
str(job.local_path),
|
|
123
|
+
job.remote_path,
|
|
124
|
+
progress_callback=progress,
|
|
125
|
+
)
|
|
126
|
+
job.status = TransferStatus.COMPLETED
|
|
127
|
+
job.transferred_bytes = job.total_bytes
|
|
128
|
+
except Exception as e:
|
|
129
|
+
job.status = TransferStatus.FAILED
|
|
130
|
+
job.error = str(e)
|
|
131
|
+
self._notify(job)
|
|
132
|
+
|
|
133
|
+
def cancel(self, job_id: str) -> None:
|
|
134
|
+
"""Cancel a pending or in-progress job."""
|
|
135
|
+
job = self._jobs.get(job_id)
|
|
136
|
+
if job and job.status in (TransferStatus.PENDING, TransferStatus.IN_PROGRESS):
|
|
137
|
+
job.status = TransferStatus.CANCELLED
|
|
138
|
+
self._notify(job)
|
|
139
|
+
|
|
140
|
+
def clear_completed(self) -> None:
|
|
141
|
+
"""Remove completed, failed, and cancelled jobs from the list."""
|
|
142
|
+
self._jobs = {
|
|
143
|
+
k: v
|
|
144
|
+
for k, v in self._jobs.items()
|
|
145
|
+
if v.status in (TransferStatus.PENDING, TransferStatus.IN_PROGRESS)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
def _notify(self, job: TransferJob) -> None:
|
|
149
|
+
if self._on_update:
|
|
150
|
+
self._on_update(job)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Progress callback adapter — bridges SDK callbacks to the transfer manager."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cloudscope.models.transfer import TransferJob
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProgressAdapter:
|
|
9
|
+
"""Wraps a TransferJob and updates it from SDK progress callbacks."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, job: TransferJob, on_update: callable | None = None) -> None:
|
|
12
|
+
self._job = job
|
|
13
|
+
self._on_update = on_update
|
|
14
|
+
|
|
15
|
+
def __call__(self, bytes_transferred: int, total_bytes: int) -> None:
|
|
16
|
+
self._job.transferred_bytes = bytes_transferred
|
|
17
|
+
if total_bytes > 0:
|
|
18
|
+
self._job.total_bytes = total_bytes
|
|
19
|
+
if self._on_update:
|
|
20
|
+
self._on_update(self._job)
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Command palette provider for CloudScope."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import partial
|
|
6
|
+
|
|
7
|
+
from textual.command import Hit, Hits, Provider
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CloudScopeCommands(Provider):
|
|
11
|
+
"""Provides app-specific commands for the Ctrl+K command palette."""
|
|
12
|
+
|
|
13
|
+
COMMANDS: list[tuple[str, str, str]] = [
|
|
14
|
+
("Switch to S3", "switch_backend('s3')", "Connect to Amazon S3"),
|
|
15
|
+
("Switch to GCS", "switch_backend('gcs')", "Connect to Google Cloud Storage"),
|
|
16
|
+
("Switch to Google Drive", "switch_backend('drive')", "Connect to Google Drive"),
|
|
17
|
+
("Download selected file", "download", "Download the currently selected file"),
|
|
18
|
+
("Upload file", "upload", "Upload a local file to the current location"),
|
|
19
|
+
("Create new folder", "new_folder", "Create a new folder in the current location"),
|
|
20
|
+
("Delete selected", "delete_file", "Delete the currently selected file"),
|
|
21
|
+
("Refresh", "refresh", "Refresh the current view"),
|
|
22
|
+
("Open sync", "open_sync", "Open sync configuration"),
|
|
23
|
+
("Open settings", "open_settings", "Open application settings"),
|
|
24
|
+
("Open auth setup", "open_auth", "Configure authentication"),
|
|
25
|
+
("Quit", "quit", "Exit CloudScope"),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
async def search(self, query: str) -> Hits:
|
|
29
|
+
matcher = self.matcher(query)
|
|
30
|
+
for name, action, help_text in self.COMMANDS:
|
|
31
|
+
score = matcher.match(name)
|
|
32
|
+
if score > 0:
|
|
33
|
+
yield Hit(
|
|
34
|
+
score,
|
|
35
|
+
matcher.highlight(name),
|
|
36
|
+
partial(self.app.run_action, action),
|
|
37
|
+
help=help_text,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
async def discover(self) -> Hits:
|
|
41
|
+
for name, action, help_text in self.COMMANDS:
|
|
42
|
+
yield Hit(
|
|
43
|
+
1.0,
|
|
44
|
+
name,
|
|
45
|
+
partial(self.app.run_action, action),
|
|
46
|
+
help=help_text,
|
|
47
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Generic confirmation modal dialog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Horizontal, Vertical
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Button, Label, Static
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfirmDialog(ModalScreen[bool]):
|
|
12
|
+
"""A modal dialog that asks for yes/no confirmation."""
|
|
13
|
+
|
|
14
|
+
DEFAULT_CSS = """
|
|
15
|
+
ConfirmDialog {
|
|
16
|
+
align: center middle;
|
|
17
|
+
background: rgba(10, 10, 26, 0.85);
|
|
18
|
+
}
|
|
19
|
+
ConfirmDialog > Vertical {
|
|
20
|
+
width: 50;
|
|
21
|
+
height: auto;
|
|
22
|
+
max-height: 15;
|
|
23
|
+
border: double #7c3aed;
|
|
24
|
+
background: #16213e;
|
|
25
|
+
padding: 1 2;
|
|
26
|
+
}
|
|
27
|
+
ConfirmDialog .title {
|
|
28
|
+
text-style: bold;
|
|
29
|
+
color: #e2e8f0;
|
|
30
|
+
width: 100%;
|
|
31
|
+
content-align: center middle;
|
|
32
|
+
margin-bottom: 1;
|
|
33
|
+
}
|
|
34
|
+
ConfirmDialog .message {
|
|
35
|
+
color: #94a3b8;
|
|
36
|
+
width: 100%;
|
|
37
|
+
margin-bottom: 1;
|
|
38
|
+
}
|
|
39
|
+
ConfirmDialog Horizontal {
|
|
40
|
+
width: 100%;
|
|
41
|
+
height: auto;
|
|
42
|
+
align: center middle;
|
|
43
|
+
}
|
|
44
|
+
ConfirmDialog Button {
|
|
45
|
+
margin: 0 1;
|
|
46
|
+
background: #1e2a4a;
|
|
47
|
+
color: #e2e8f0;
|
|
48
|
+
border: tall #2a2a4a;
|
|
49
|
+
}
|
|
50
|
+
ConfirmDialog Button:hover {
|
|
51
|
+
background: #2a2a4a;
|
|
52
|
+
}
|
|
53
|
+
ConfirmDialog #yes {
|
|
54
|
+
background: #ef4444;
|
|
55
|
+
color: #e2e8f0;
|
|
56
|
+
border: tall #dc2626;
|
|
57
|
+
}
|
|
58
|
+
ConfirmDialog #yes:hover {
|
|
59
|
+
background: #dc2626;
|
|
60
|
+
}
|
|
61
|
+
ConfirmDialog #no {
|
|
62
|
+
background: #7c3aed;
|
|
63
|
+
color: #e2e8f0;
|
|
64
|
+
border: tall #5b21b6;
|
|
65
|
+
}
|
|
66
|
+
ConfirmDialog #no:hover {
|
|
67
|
+
background: #5b21b6;
|
|
68
|
+
}
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
title: str = "Confirm",
|
|
74
|
+
message: str = "Are you sure?",
|
|
75
|
+
yes_label: str = "Yes",
|
|
76
|
+
no_label: str = "No",
|
|
77
|
+
) -> None:
|
|
78
|
+
super().__init__()
|
|
79
|
+
self._title = title
|
|
80
|
+
self._message = message
|
|
81
|
+
self._yes_label = yes_label
|
|
82
|
+
self._no_label = no_label
|
|
83
|
+
|
|
84
|
+
def compose(self) -> ComposeResult:
|
|
85
|
+
with Vertical():
|
|
86
|
+
yield Label(self._title, classes="title")
|
|
87
|
+
yield Static(self._message, classes="message")
|
|
88
|
+
with Horizontal():
|
|
89
|
+
yield Button(self._yes_label, id="yes")
|
|
90
|
+
yield Button(self._no_label, id="no")
|
|
91
|
+
|
|
92
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
93
|
+
self.dismiss(event.button.id == "yes")
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Download destination picker dialog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Horizontal, Vertical
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import Button, DirectoryTree, Input, Label, Static
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DownloadDialog(ModalScreen[str | None]):
|
|
14
|
+
"""Modal for selecting a local download destination."""
|
|
15
|
+
|
|
16
|
+
DEFAULT_CSS = """
|
|
17
|
+
DownloadDialog {
|
|
18
|
+
align: center middle;
|
|
19
|
+
background: rgba(10, 10, 26, 0.85);
|
|
20
|
+
}
|
|
21
|
+
DownloadDialog > Vertical {
|
|
22
|
+
width: 70;
|
|
23
|
+
height: 30;
|
|
24
|
+
border: double #7c3aed;
|
|
25
|
+
background: #16213e;
|
|
26
|
+
padding: 1 2;
|
|
27
|
+
}
|
|
28
|
+
DownloadDialog .title {
|
|
29
|
+
text-style: bold;
|
|
30
|
+
color: #e2e8f0;
|
|
31
|
+
width: 100%;
|
|
32
|
+
content-align: center middle;
|
|
33
|
+
margin-bottom: 1;
|
|
34
|
+
}
|
|
35
|
+
DownloadDialog Static {
|
|
36
|
+
color: #94a3b8;
|
|
37
|
+
}
|
|
38
|
+
DownloadDialog DirectoryTree {
|
|
39
|
+
height: 1fr;
|
|
40
|
+
margin-bottom: 1;
|
|
41
|
+
background: #1a1a2e;
|
|
42
|
+
scrollbar-size: 1 1;
|
|
43
|
+
scrollbar-background: #1a1a2e;
|
|
44
|
+
scrollbar-color: #2a2a4a;
|
|
45
|
+
scrollbar-color-hover: #475569;
|
|
46
|
+
}
|
|
47
|
+
DownloadDialog Input {
|
|
48
|
+
margin-bottom: 1;
|
|
49
|
+
background: #1a1a2e;
|
|
50
|
+
color: #e2e8f0;
|
|
51
|
+
border: tall #2a2a4a;
|
|
52
|
+
}
|
|
53
|
+
DownloadDialog Input:focus {
|
|
54
|
+
border: tall #7c3aed;
|
|
55
|
+
}
|
|
56
|
+
DownloadDialog Horizontal {
|
|
57
|
+
width: 100%;
|
|
58
|
+
height: auto;
|
|
59
|
+
align: center middle;
|
|
60
|
+
}
|
|
61
|
+
DownloadDialog Button {
|
|
62
|
+
margin: 0 1;
|
|
63
|
+
background: #1e2a4a;
|
|
64
|
+
color: #e2e8f0;
|
|
65
|
+
border: tall #2a2a4a;
|
|
66
|
+
}
|
|
67
|
+
DownloadDialog Button:hover {
|
|
68
|
+
background: #2a2a4a;
|
|
69
|
+
}
|
|
70
|
+
DownloadDialog #download {
|
|
71
|
+
background: #7c3aed;
|
|
72
|
+
color: #e2e8f0;
|
|
73
|
+
border: tall #5b21b6;
|
|
74
|
+
}
|
|
75
|
+
DownloadDialog #download:hover {
|
|
76
|
+
background: #5b21b6;
|
|
77
|
+
}
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, file_name: str, default_dir: str | None = None) -> None:
|
|
81
|
+
super().__init__()
|
|
82
|
+
self._file_name = file_name
|
|
83
|
+
self._default_dir = default_dir or str(Path.home() / "Downloads")
|
|
84
|
+
|
|
85
|
+
def compose(self) -> ComposeResult:
|
|
86
|
+
with Vertical():
|
|
87
|
+
yield Label(f"Download: {self._file_name}", classes="title")
|
|
88
|
+
yield Static("Select destination directory:")
|
|
89
|
+
yield DirectoryTree(self._default_dir, id="dir-tree")
|
|
90
|
+
yield Input(value=self._default_dir, placeholder="Destination path", id="path-input")
|
|
91
|
+
with Horizontal():
|
|
92
|
+
yield Button("Download", id="download")
|
|
93
|
+
yield Button("Cancel", id="cancel")
|
|
94
|
+
|
|
95
|
+
def on_directory_tree_directory_selected(
|
|
96
|
+
self, event: DirectoryTree.DirectorySelected
|
|
97
|
+
) -> None:
|
|
98
|
+
path_input = self.query_one("#path-input", Input)
|
|
99
|
+
path_input.value = str(event.path)
|
|
100
|
+
|
|
101
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
102
|
+
if event.button.id == "download":
|
|
103
|
+
path_input = self.query_one("#path-input", Input)
|
|
104
|
+
dest_dir = path_input.value.strip()
|
|
105
|
+
if dest_dir:
|
|
106
|
+
full_path = str(Path(dest_dir) / self._file_name)
|
|
107
|
+
self.dismiss(full_path)
|
|
108
|
+
else:
|
|
109
|
+
self.notify("Please select a destination", severity="warning")
|
|
110
|
+
else:
|
|
111
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""New folder creation dialog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Horizontal, Vertical
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Button, Input, Label
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NewFolderDialog(ModalScreen[str | None]):
|
|
12
|
+
"""Modal for entering a new folder name."""
|
|
13
|
+
|
|
14
|
+
DEFAULT_CSS = """
|
|
15
|
+
NewFolderDialog {
|
|
16
|
+
align: center middle;
|
|
17
|
+
background: rgba(10, 10, 26, 0.85);
|
|
18
|
+
}
|
|
19
|
+
NewFolderDialog > Vertical {
|
|
20
|
+
width: 50;
|
|
21
|
+
height: auto;
|
|
22
|
+
border: double #7c3aed;
|
|
23
|
+
background: #16213e;
|
|
24
|
+
padding: 1 2;
|
|
25
|
+
}
|
|
26
|
+
NewFolderDialog .title {
|
|
27
|
+
text-style: bold;
|
|
28
|
+
color: #e2e8f0;
|
|
29
|
+
width: 100%;
|
|
30
|
+
content-align: center middle;
|
|
31
|
+
margin-bottom: 1;
|
|
32
|
+
}
|
|
33
|
+
NewFolderDialog Input {
|
|
34
|
+
margin-bottom: 1;
|
|
35
|
+
background: #1a1a2e;
|
|
36
|
+
color: #e2e8f0;
|
|
37
|
+
border: tall #2a2a4a;
|
|
38
|
+
}
|
|
39
|
+
NewFolderDialog Input:focus {
|
|
40
|
+
border: tall #7c3aed;
|
|
41
|
+
}
|
|
42
|
+
NewFolderDialog Horizontal {
|
|
43
|
+
width: 100%;
|
|
44
|
+
height: auto;
|
|
45
|
+
align: center middle;
|
|
46
|
+
}
|
|
47
|
+
NewFolderDialog Button {
|
|
48
|
+
margin: 0 1;
|
|
49
|
+
background: #1e2a4a;
|
|
50
|
+
color: #e2e8f0;
|
|
51
|
+
border: tall #2a2a4a;
|
|
52
|
+
}
|
|
53
|
+
NewFolderDialog Button:hover {
|
|
54
|
+
background: #2a2a4a;
|
|
55
|
+
}
|
|
56
|
+
NewFolderDialog #create {
|
|
57
|
+
background: #7c3aed;
|
|
58
|
+
color: #e2e8f0;
|
|
59
|
+
border: tall #5b21b6;
|
|
60
|
+
}
|
|
61
|
+
NewFolderDialog #create:hover {
|
|
62
|
+
background: #5b21b6;
|
|
63
|
+
}
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, current_path: str = "") -> None:
|
|
67
|
+
super().__init__()
|
|
68
|
+
self._current_path = current_path
|
|
69
|
+
|
|
70
|
+
def compose(self) -> ComposeResult:
|
|
71
|
+
with Vertical():
|
|
72
|
+
yield Label("Create New Folder", classes="title")
|
|
73
|
+
yield Input(placeholder="Folder name", id="folder-name")
|
|
74
|
+
with Horizontal():
|
|
75
|
+
yield Button("Create", id="create")
|
|
76
|
+
yield Button("Cancel", id="cancel")
|
|
77
|
+
|
|
78
|
+
def on_mount(self) -> None:
|
|
79
|
+
self.query_one("#folder-name", Input).focus()
|
|
80
|
+
|
|
81
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
82
|
+
self._submit()
|
|
83
|
+
|
|
84
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
85
|
+
if event.button.id == "create":
|
|
86
|
+
self._submit()
|
|
87
|
+
else:
|
|
88
|
+
self.dismiss(None)
|
|
89
|
+
|
|
90
|
+
def _submit(self) -> None:
|
|
91
|
+
name = self.query_one("#folder-name", Input).value.strip()
|
|
92
|
+
if name:
|
|
93
|
+
path = f"{self._current_path}/{name}".lstrip("/")
|
|
94
|
+
self.dismiss(path)
|
|
95
|
+
else:
|
|
96
|
+
self.notify("Please enter a folder name", severity="warning")
|