data-sourcerer 0.2.3__py3-none-any.whl → 0.3.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.
Files changed (43) hide show
  1. {data_sourcerer-0.2.3.dist-info → data_sourcerer-0.3.0.dist-info}/METADATA +1 -1
  2. {data_sourcerer-0.2.3.dist-info → data_sourcerer-0.3.0.dist-info}/RECORD +42 -28
  3. sourcerer/__init__.py +1 -1
  4. sourcerer/domain/access_credentials/entities.py +3 -1
  5. sourcerer/domain/access_credentials/repositories.py +1 -1
  6. sourcerer/domain/storage/__init__.py +0 -0
  7. sourcerer/domain/storage/entities.py +27 -0
  8. sourcerer/domain/storage/repositories.py +31 -0
  9. sourcerer/infrastructure/access_credentials/repositories.py +3 -2
  10. sourcerer/infrastructure/access_credentials/services.py +9 -25
  11. sourcerer/infrastructure/db/models.py +33 -2
  12. sourcerer/infrastructure/storage/__init__.py +0 -0
  13. sourcerer/infrastructure/storage/repositories.py +72 -0
  14. sourcerer/infrastructure/storage/services.py +37 -0
  15. sourcerer/infrastructure/storage_provider/services/gcp.py +1 -1
  16. sourcerer/infrastructure/utils.py +2 -1
  17. sourcerer/presentation/di_container.py +15 -0
  18. sourcerer/presentation/screens/file_system_finder/main.py +5 -4
  19. sourcerer/presentation/screens/file_system_finder/widgets/file_system_navigator.py +16 -13
  20. sourcerer/presentation/screens/main/main.py +63 -8
  21. sourcerer/presentation/screens/main/messages/select_storage_item.py +1 -0
  22. sourcerer/presentation/screens/main/mixins/resize_containers_watcher_mixin.py +2 -1
  23. sourcerer/presentation/screens/main/widgets/storage_content.py +187 -77
  24. sourcerer/presentation/screens/main/widgets/storage_list_sidebar.py +99 -31
  25. sourcerer/presentation/screens/preview_content/main.py +15 -3
  26. sourcerer/presentation/screens/provider_creds_list/main.py +29 -8
  27. sourcerer/presentation/screens/provider_creds_registration/main.py +7 -4
  28. sourcerer/presentation/screens/shared/widgets/spinner.py +57 -0
  29. sourcerer/presentation/screens/storage_action_progress/main.py +3 -5
  30. sourcerer/presentation/screens/storages_list/__init__.py +0 -0
  31. sourcerer/presentation/screens/storages_list/main.py +180 -0
  32. sourcerer/presentation/screens/storages_list/messages/__init__.py +0 -0
  33. sourcerer/presentation/screens/storages_list/messages/reload_storages_request.py +8 -0
  34. sourcerer/presentation/screens/storages_list/styles.tcss +55 -0
  35. sourcerer/presentation/screens/storages_registration/__init__.py +0 -0
  36. sourcerer/presentation/screens/storages_registration/main.py +100 -0
  37. sourcerer/presentation/screens/storages_registration/styles.tcss +41 -0
  38. sourcerer/presentation/settings.py +29 -16
  39. sourcerer/presentation/utils.py +9 -1
  40. sourcerer/presentation/screens/shared/widgets/loader.py +0 -31
  41. {data_sourcerer-0.2.3.dist-info → data_sourcerer-0.3.0.dist-info}/WHEEL +0 -0
  42. {data_sourcerer-0.2.3.dist-info → data_sourcerer-0.3.0.dist-info}/entry_points.txt +0 -0
  43. {data_sourcerer-0.2.3.dist-info → data_sourcerer-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -21,6 +21,7 @@ from sourcerer.presentation.screens.shared.containers import (
21
21
  ScrollHorizontalContainerWithNoBindings,
22
22
  ScrollVerticalContainerWithNoBindings,
23
23
  )
24
+ from sourcerer.presentation.settings import KeyBindings
24
25
  from sourcerer.settings import DIRECTORY_ICON, DOUBLE_CLICK_THRESHOLD, FILE_ICON
25
26
 
26
27
 
@@ -48,7 +49,7 @@ class FileSystemWidget(Widget):
48
49
  background: $block-cursor-blurred-background;
49
50
  text-style: $block-cursor-blurred-text-style;
50
51
  }
51
-
52
+
52
53
  .folder-name {
53
54
  text-overflow: ellipsis;
54
55
  text-wrap: nowrap;
@@ -161,7 +162,7 @@ class FileSystemWidget(Widget):
161
162
  Calls the `on_click` method if the pressed key is "enter", which may trigger
162
163
  folder navigation or file opening depending on the widget's context.
163
164
  """
164
- if event.key == "enter":
165
+ if event.key == KeyBindings.ENTER.value:
165
166
  self.on_file_select()
166
167
 
167
168
  def on_file_select(self):
@@ -232,11 +233,13 @@ class FileSystemNavigator(ScrollHorizontalContainerWithNoBindings):
232
233
 
233
234
  # Consolidate key binding data
234
235
  BINDINGS: ClassVar[list[BindingType]] = [
235
- Binding("enter", "select_cursor", "Select", show=False),
236
- Binding("up", "cursor_up", "Cursor up", show=False),
237
- Binding("down", "cursor_down", "Cursor down", show=False),
238
- Binding("left", "cursor_left", "Cursor left", show=False),
239
- Binding("right", "cursor_right", "Cursor right", show=False),
236
+ Binding(KeyBindings.ENTER.value, "select_cursor", "Select", show=False),
237
+ Binding(KeyBindings.ARROW_UP.value, "cursor_up", "Cursor up", show=False),
238
+ Binding(KeyBindings.ARROW_DOWN.value, "cursor_down", "Cursor down", show=False),
239
+ Binding(KeyBindings.ARROW_LEFT.value, "cursor_left", "Cursor left", show=False),
240
+ Binding(
241
+ KeyBindings.ARROW_RIGHT.value, "cursor_right", "Cursor right", show=False
242
+ ),
240
243
  ]
241
244
 
242
245
  MAIN_CONTAINER_ID: ClassVar[str] = "dirs_content"
@@ -306,9 +309,9 @@ class FileSystemNavigator(ScrollHorizontalContainerWithNoBindings):
306
309
  )
307
310
  self._focus_first_child(path_listing_container)
308
311
 
309
- self.path_listing_containers_uuids[str(self.work_dir)] = (
310
- path_listing_container.id
311
- )
312
+ self.path_listing_containers_uuids[
313
+ str(self.work_dir)
314
+ ] = path_listing_container.id
312
315
 
313
316
  def action_cursor_down(self) -> None:
314
317
  """
@@ -626,9 +629,9 @@ class FileSystemNavigator(ScrollHorizontalContainerWithNoBindings):
626
629
  await self._mount_path_listing_container(path_listing_container)
627
630
 
628
631
  if str(folder_path) not in self.path_listing_containers_uuids:
629
- self.path_listing_containers_uuids[str(folder_path)] = (
630
- path_listing_container.id
631
- )
632
+ self.path_listing_containers_uuids[
633
+ str(folder_path)
634
+ ] = path_listing_container.id
632
635
 
633
636
  @on(FileSystemWidget.Focus)
634
637
  def on_folder_focus(self, event: FileSystemWidget.Focus):
@@ -2,19 +2,23 @@ import time
2
2
  import traceback
3
3
  from concurrent.futures import ThreadPoolExecutor
4
4
  from pathlib import Path
5
+ from typing import ClassVar
5
6
 
7
+ from dependency_injector.wiring import Provide
6
8
  from textual import on, work
7
9
  from textual.app import App, ComposeResult
8
- from textual.binding import Binding
10
+ from textual.binding import Binding, BindingType
9
11
  from textual.containers import Horizontal
10
12
  from textual.reactive import reactive
11
13
  from textual.widgets import Footer
12
14
 
15
+ from sourcerer.domain.storage_provider.entities import Storage
13
16
  from sourcerer.infrastructure.access_credentials.services import CredentialsService
14
17
  from sourcerer.infrastructure.storage_provider.exceptions import (
15
18
  ListStorageItemsError,
16
19
  )
17
20
  from sourcerer.infrastructure.utils import generate_uuid
21
+ from sourcerer.presentation.di_container import DiContainer
18
22
  from sourcerer.presentation.screens.critical_error.main import CriticalErrorScreen
19
23
  from sourcerer.presentation.screens.file_system_finder.main import (
20
24
  FileSystemNavigationModal,
@@ -55,6 +59,8 @@ from sourcerer.presentation.screens.storage_action_progress.main import (
55
59
  StorageActionProgressScreen,
56
60
  UploadKey,
57
61
  )
62
+ from sourcerer.presentation.screens.storages_list.main import StoragesListScreen
63
+ from sourcerer.presentation.settings import KeyBindings
58
64
  from sourcerer.presentation.themes.github_dark import github_dark_theme
59
65
  from sourcerer.presentation.utils import (
60
66
  get_provider_service_by_access_credentials,
@@ -93,12 +99,28 @@ class Sourcerer(App, ResizeContainersWatcherMixin):
93
99
  """
94
100
 
95
101
  CSS_PATH = "styles.tcss"
96
- BINDINGS = [Binding("ctrl+r", "registrations", "Registrations list")]
102
+ BINDINGS: ClassVar[list[BindingType]] = [
103
+ Binding("ctrl+r", "registrations", "Registrations list"),
104
+ Binding("ctrl+s", "storages", "Storages list"),
105
+ Binding(
106
+ KeyBindings.ARROW_LEFT.value, "focus_sidebar", "Focus sidebar", show=False
107
+ ),
108
+ Binding(
109
+ KeyBindings.ARROW_RIGHT.value, "focus_content", "Focus content", show=False
110
+ ),
111
+ ]
97
112
  is_storage_list_loading = reactive(False, recompose=True)
98
113
 
99
- def __init__(self, *args, **kwargs):
114
+ def __init__(
115
+ self,
116
+ credentials_service: CredentialsService = Provide[
117
+ DiContainer.credentials_service
118
+ ],
119
+ *args,
120
+ **kwargs,
121
+ ):
100
122
  super().__init__(*args, **kwargs)
101
- self.credentials_service = CredentialsService()
123
+ self.credentials_service = credentials_service
102
124
  self.storage_list_sidebar = StorageListSidebar(id="storage_list_sidebar")
103
125
  self.storage_content = StorageContentContainer(id="storage_content_container")
104
126
  self.load_percentage = 0
@@ -130,6 +152,18 @@ class Sourcerer(App, ResizeContainersWatcherMixin):
130
152
  self.theme = "github-dark"
131
153
  self.init_storages_list()
132
154
 
155
+ def action_focus_content(self):
156
+ """
157
+ Focuses the storage content container.
158
+ """
159
+ self.storage_content.focus()
160
+
161
+ def action_focus_sidebar(self):
162
+ """
163
+ Focuses the storage list sidebar.
164
+ """
165
+ self.storage_list_sidebar.focus()
166
+
133
167
  def action_registrations(self):
134
168
  """
135
169
  Opens the provider credentials list screen and refreshes the storage list.
@@ -142,6 +176,9 @@ class Sourcerer(App, ResizeContainersWatcherMixin):
142
176
  """
143
177
  self.app.push_screen(ProviderCredsListScreen(), callback=self.refresh_storages)
144
178
 
179
+ def action_storages(self):
180
+ self.app.push_screen(StoragesListScreen(), callback=self.refresh_storages)
181
+
145
182
  def refresh_storages(self, *args, **kwargs):
146
183
  """
147
184
  Refreshes the storage list by clearing the current storages and
@@ -206,7 +243,11 @@ class Sourcerer(App, ResizeContainersWatcherMixin):
206
243
  5. Notify the user if an error occurs during the retrieval process.
207
244
  """
208
245
  self.refresh_storage_content(
209
- event.access_credentials_uuid, event.name, event.path, event.prefix
246
+ event.access_credentials_uuid,
247
+ event.name,
248
+ event.path,
249
+ event.prefix,
250
+ event.focus_content,
210
251
  )
211
252
 
212
253
  @on(UploadRequest)
@@ -381,7 +422,12 @@ class Sourcerer(App, ResizeContainersWatcherMixin):
381
422
  self.uncheck_files_request(UncheckFilesRequest(keys=[]))
382
423
 
383
424
  def refresh_storage_content(
384
- self, access_credentials_uuid, storage_name, path, prefix=None
425
+ self,
426
+ access_credentials_uuid,
427
+ storage_name,
428
+ path,
429
+ prefix=None,
430
+ focus_content=False,
385
431
  ):
386
432
  """
387
433
  Refreshes the storage content display with items from the specified storage path.
@@ -407,6 +453,7 @@ class Sourcerer(App, ResizeContainersWatcherMixin):
407
453
  self.storage_content.storage_content = None
408
454
  self.storage_content.selected_files = set()
409
455
  self.storage_content.selected_files_n = 0
456
+ self.storage_content.focus_content = focus_content
410
457
 
411
458
  provider_service = get_provider_service_by_access_uuid(
412
459
  access_credentials_uuid, self.credentials_service
@@ -421,7 +468,7 @@ class Sourcerer(App, ResizeContainersWatcherMixin):
421
468
  **params
422
469
  )
423
470
  except ListStorageItemsError as e:
424
- self.notify_error(f"""Could not extract storage content \n{str(e)}""")
471
+ self.notify_error(f"""Could not extract storage content \n{e}""")
425
472
 
426
473
  def _upload_file(
427
474
  self,
@@ -509,7 +556,15 @@ class Sourcerer(App, ResizeContainersWatcherMixin):
509
556
 
510
557
  try:
511
558
  storages = provider_service.list_storages()
512
- self.storage_list_sidebar.storages[credentials.uuid] = storages
559
+ storage_names = [storage.storage for storage in storages]
560
+ registered_storages = [
561
+ Storage(credentials.provider, storage.name, storage.created_at)
562
+ for storage in credentials.storages
563
+ if storage.name not in storage_names
564
+ ]
565
+ self.storage_list_sidebar.storages[credentials.uuid] = (
566
+ storages + registered_storages
567
+ )
513
568
  self.storage_list_sidebar.last_update_timestamp = time.time()
514
569
  except Exception:
515
570
  self.notify_error(f"Could not get storages list for {credentials.name}!")
@@ -9,3 +9,4 @@ class SelectStorageItem(Message):
9
9
  path: str | None = None
10
10
  access_credentials_uuid: str | None = None
11
11
  prefix: str | None = None
12
+ focus_content: bool = False
@@ -1,4 +1,5 @@
1
1
  import time
2
+ from typing import ClassVar
2
3
 
3
4
  from textual.events import MouseMove, MouseUp
4
5
 
@@ -28,7 +29,7 @@ class ResizeContainersWatcherMixin:
28
29
  and is inherited from textual App.
29
30
  """
30
31
 
31
- required_methods = ["query_one", "refresh"]
32
+ required_methods: ClassVar[list[str]] = ["query_one", "refresh"]
32
33
 
33
34
  def __init_subclass__(cls, **kwargs):
34
35
  super().__init_subclass__(**kwargs)
@@ -7,18 +7,20 @@ display with search functionality.
7
7
 
8
8
  import contextlib
9
9
  import os.path
10
+ from abc import abstractmethod
10
11
  from dataclasses import dataclass
11
12
  from enum import Enum, auto
13
+ from typing import ClassVar, Self
12
14
 
13
15
  from textual import events, on
14
16
  from textual.app import ComposeResult
17
+ from textual.binding import Binding, BindingType
15
18
  from textual.containers import (
16
19
  Center,
17
20
  Container,
18
21
  Horizontal,
19
22
  Middle,
20
23
  Vertical,
21
- VerticalScroll,
22
24
  )
23
25
  from textual.css.query import NoMatches
24
26
  from textual.message import Message
@@ -38,8 +40,11 @@ from sourcerer.presentation.screens.main.messages.uncheck_files_request import (
38
40
  UncheckFilesRequest,
39
41
  )
40
42
  from sourcerer.presentation.screens.main.messages.upload_request import UploadRequest
43
+ from sourcerer.presentation.screens.shared.containers import (
44
+ ScrollVerticalContainerWithNoBindings,
45
+ )
41
46
  from sourcerer.presentation.screens.shared.widgets.button import Button
42
- from sourcerer.presentation.settings import NO_DATA_LOGO
47
+ from sourcerer.presentation.settings import NO_DATA_LOGO, KeyBindings
43
48
  from sourcerer.settings import (
44
49
  DIRECTORY_ICON,
45
50
  DOWNLOAD_ICON,
@@ -91,6 +96,10 @@ class ActionType(Enum):
91
96
  return action_map[action_str]
92
97
 
93
98
 
99
+ class UnfocusableCheckbox(Checkbox):
100
+ can_focus = False
101
+
102
+
94
103
  class FileMetaLabel(Static):
95
104
  """Widget for displaying file metadata information.
96
105
 
@@ -120,6 +129,16 @@ class PathSelector(Label):
120
129
  access_credentials_uuid: UUID of the access credentials being used
121
130
  """
122
131
 
132
+ can_focus = True
133
+
134
+ DEFAULT_CSS = """
135
+ PathSelector {
136
+ &:focus {
137
+ background: $secondary-lighten-2;
138
+ }
139
+ }
140
+ """
141
+
123
142
  def __init__(self, storage, path, access_credentials_uuid, *args, **kwargs):
124
143
  super().__init__(*args, **kwargs)
125
144
  self.storage = storage
@@ -128,31 +147,93 @@ class PathSelector(Label):
128
147
 
129
148
  def on_click(self, _: events.Click) -> None:
130
149
  """Handle click events to navigate to the selected path."""
150
+ self._select()
151
+
152
+ def on_key(self, event: events.Key) -> None:
153
+ """Handle key events to navigate to the selected path."""
154
+ if event.key == KeyBindings.ENTER.value:
155
+ self._select()
156
+
157
+ def _select(self):
158
+ """Select the current path."""
131
159
  self.post_message(
132
- SelectStorageItem(self.storage, self.path, self.access_credentials_uuid)
160
+ SelectStorageItem(
161
+ self.storage,
162
+ self.path,
163
+ self.access_credentials_uuid,
164
+ focus_content=True,
165
+ )
133
166
  )
134
167
 
135
168
 
136
- class FolderItem(Horizontal):
169
+ class StorageContentItem(Horizontal):
170
+ DEFAULT_CSS = """
171
+ StorageContentItem.active {
172
+ background: $secondary;
173
+ color: $panel;
174
+ }
175
+ StorageContentItem:focus {
176
+ background: $secondary-lighten-2;
177
+ color: $panel;
178
+ }
179
+ """
180
+
181
+ can_focus = True
182
+
183
+ def __init__(self, focus_first: bool, *args, **kwargs):
184
+ """Initialize the storage content widget."""
185
+ super().__init__(*args, **kwargs)
186
+ self.focus_first = focus_first
187
+
188
+ def on_mount(self) -> None:
189
+ """Handle the mounting of the widget."""
190
+ if self.focus_first and self.first_child:
191
+ self.focus()
192
+
193
+ @abstractmethod
194
+ def _select(self, widget=None):
195
+ raise NotImplementedError
196
+
197
+ def on_click(self, event: events.Click) -> None:
198
+ """Handle click events to navigate into the folder."""
199
+ self._select(event.widget)
200
+
201
+ def on_key(self, event: events.Key) -> None:
202
+ """Handle key events to navigate into the folder."""
203
+ if event.key == KeyBindings.ARROW_UP.value:
204
+ if self.first_child:
205
+ self.parent.children[-1].focus() # type: ignore
206
+ return
207
+ self.screen.focus_previous()
208
+ if event.key == KeyBindings.ARROW_DOWN.value:
209
+ if self.last_child:
210
+ self.parent.children[0].focus() # type: ignore
211
+ return
212
+ self.screen.focus_next()
213
+
214
+ @on(events.Enter)
215
+ @on(events.Leave)
216
+ def on_enter(self, _: events.Enter):
217
+ with contextlib.suppress(Exception):
218
+ self.set_class(self.is_mouse_over, "active")
219
+
220
+
221
+ class FolderItem(StorageContentItem):
137
222
  """Widget for displaying and interacting with folder items.
138
223
 
139
224
  This widget represents a folder in the storage content view, allowing
140
225
  navigation into the folder and visual feedback on hover/selection.
141
226
  """
142
227
 
143
- DEFAULT_CSS = """
144
- FolderItem {
145
- margin-bottom: 1;
146
- }
147
- FolderItem.active {
148
- background: $secondary;
149
- color: $panel;
150
- }
151
- """
152
- can_focus = False
153
-
154
228
  def __init__(
155
- self, storage, access_credentials_uuid, parent_path, folder, *args, **kwargs
229
+ self,
230
+ storage,
231
+ access_credentials_uuid,
232
+ parent_path,
233
+ folder,
234
+ focus_first,
235
+ *args,
236
+ **kwargs,
156
237
  ):
157
238
  """Initialize a folder item widget.
158
239
 
@@ -162,7 +243,7 @@ class FolderItem(Horizontal):
162
243
  parent_path: The parent path of the folder
163
244
  folder: The folder name
164
245
  """
165
- super().__init__(*args, **kwargs)
246
+ super().__init__(focus_first, *args, **kwargs)
166
247
  self.storage = storage
167
248
  self.access_credentials_uuid = access_credentials_uuid
168
249
  self.parent_path = parent_path
@@ -172,26 +253,29 @@ class FolderItem(Horizontal):
172
253
  """Compose the folder item layout with folder name and icon."""
173
254
  yield Label(f"{DIRECTORY_ICON}{self.folder.key}", markup=False)
174
255
 
175
- def on_click(self, _: events.Click) -> None:
176
- """Handle click events to navigate into the folder."""
256
+ def _select(self, widget=None):
257
+ """Select the folder."""
177
258
  path = self.folder.key
178
259
  if self.parent_path:
179
260
  path = self.parent_path.strip("/") + "/" + path
180
261
 
181
262
  self.post_message(
182
- SelectStorageItem(self.storage, path, self.access_credentials_uuid)
263
+ SelectStorageItem(
264
+ self.storage, path, self.access_credentials_uuid, focus_content=True
265
+ )
183
266
  )
184
267
 
185
- def on_mouse_move(self, _) -> None:
186
- """Handle mouse move events to highlight the folder."""
187
- self.add_class("active")
188
-
189
- def on_leave(self, _) -> None:
190
- """Handle mouse leave events to remove highlight."""
191
- self.remove_class("active")
268
+ def on_key(self, event: events.Key) -> None:
269
+ """Handle key events to navigate into the folder."""
270
+ if event.key in (KeyBindings.ARROW_UP.value, KeyBindings.ARROW_DOWN.value):
271
+ event.prevent_default()
272
+ if event.key == KeyBindings.ENTER.value:
273
+ self._select()
274
+ return
275
+ super().on_key(event)
192
276
 
193
277
 
194
- class FileItem(Horizontal):
278
+ class FileItem(StorageContentItem):
195
279
  """Widget for displaying and interacting with file items.
196
280
 
197
281
  This widget represents a file in the storage content view, allowing
@@ -199,17 +283,10 @@ class FileItem(Horizontal):
199
283
  """
200
284
 
201
285
  DEFAULT_CSS = """
202
- FileItem {
203
- margin-bottom: 1;
204
- }
205
- FileItem.active {
206
- background: $secondary;
207
- color: $panel;
208
- }
209
286
  .file_size {
210
287
  color: $primary
211
288
  }
212
- Checkbox {
289
+ UnfocusableCheckbox {
213
290
  border: none;
214
291
  padding: 0 0;
215
292
  display: none;
@@ -219,7 +296,6 @@ class FileItem(Horizontal):
219
296
  }
220
297
  }
221
298
  """
222
- can_focus = False
223
299
 
224
300
  @dataclass
225
301
  class Selected(Message):
@@ -239,11 +315,7 @@ class FileItem(Horizontal):
239
315
 
240
316
  name: str
241
317
 
242
- def on_mount(self):
243
- """Initialize the file item on mount."""
244
- self.add_class("file-item")
245
-
246
- def __init__(self, storage, parent_path, file, *args, **kwargs):
318
+ def __init__(self, storage, parent_path, file, focus_first, *args, **kwargs):
247
319
  """Initialize a file item widget.
248
320
 
249
321
  Args:
@@ -251,13 +323,13 @@ class FileItem(Horizontal):
251
323
  parent_path: The parent path of the file
252
324
  file: The file name
253
325
  """
254
- super().__init__(*args, **kwargs)
326
+ super().__init__(focus_first, *args, **kwargs)
255
327
  self.storage = storage
256
328
  self.parent_path = parent_path
257
329
  self.file = file
258
330
 
259
331
  def compose(self):
260
- yield Checkbox()
332
+ yield UnfocusableCheckbox()
261
333
  yield FileMetaLabel(
262
334
  f"{FILE_ICON} {self.file.key}", classes="file_name", markup=False
263
335
  )
@@ -268,42 +340,45 @@ class FileItem(Horizontal):
268
340
  if self.file.is_text:
269
341
  yield Button(f"{PREVIEW_ICON}", name="preview", classes="download")
270
342
 
271
- def on_mouse_move(self, _) -> None:
272
- """Handle mouse move events to highlight the file."""
273
- self.add_class("active")
274
-
275
- def on_leave(self, _) -> None:
276
- """Handle mouse leave events to remove highlight."""
277
- self.remove_class("active")
343
+ def on_key(self, event: events.Key) -> None:
344
+ """Handle key events to toggle file selection."""
345
+ if event.key in (KeyBindings.ARROW_UP.value, KeyBindings.ARROW_DOWN.value):
346
+ event.prevent_default()
347
+ if event.key == KeyBindings.ENTER.value:
348
+ checkbox = self.query_one(UnfocusableCheckbox)
349
+ checkbox.value = not checkbox.value
350
+ if checkbox.value:
351
+ self.post_message(self.Selected(self.file.key))
352
+ else:
353
+ self.post_message(self.Unselect(self.file.key))
354
+ return
355
+ super().on_key(event)
278
356
 
279
- def on_click(self, event: events.Click) -> None:
280
- """Handle click events to toggle file selection."""
357
+ def _select(self, widget=None):
281
358
  preview_button = None
282
359
  with contextlib.suppress(NoMatches):
283
360
  preview_button = self.query_one(Button)
284
361
 
285
- if event.widget is preview_button:
362
+ if widget is preview_button:
286
363
  self.post_message(self.Preview(self.file.key))
287
364
  return
288
365
 
289
- checkbox = self.query_one(Checkbox)
290
- if event.widget is not checkbox:
366
+ checkbox = self.query_one(UnfocusableCheckbox)
367
+ if widget is not checkbox:
291
368
  checkbox.value = not checkbox.value
292
369
  if checkbox.value:
293
370
  self.post_message(self.Selected(self.file.key))
294
371
  else:
295
372
  self.post_message(self.Unselect(self.file.key))
296
- event.prevent_default()
297
- event.stop()
298
373
 
299
374
  def uncheck(self):
300
375
  """Uncheck the file's checkbox."""
301
- checkbox = self.query_one(Checkbox)
376
+ checkbox = self.query_one(UnfocusableCheckbox)
302
377
  checkbox.value = False
303
378
 
304
379
  def check(self):
305
380
  """Check the file's checkbox."""
306
- checkbox = self.query_one(Checkbox)
381
+ checkbox = self.query_one(UnfocusableCheckbox)
307
382
  checkbox.value = True
308
383
 
309
384
 
@@ -340,6 +415,16 @@ class StorageContentContainer(Vertical):
340
415
  ] = reactive(None, recompose=True)
341
416
  selected_files: reactive[set] = reactive(set(), recompose=False)
342
417
  selected_files_n: reactive[int] = reactive(0, recompose=False)
418
+ focus_content: reactive[bool] = reactive(False, recompose=False)
419
+
420
+ BINDINGS: ClassVar[list[BindingType]] = [
421
+ Binding(
422
+ f"{KeyBindings.CTRL.value}+{KeyBindings.BACKSPACE.value}",
423
+ "back_to_prev_path",
424
+ "Navigate back to the previous path",
425
+ show=True,
426
+ ),
427
+ ]
343
428
 
344
429
  DEFAULT_CSS = """
345
430
 
@@ -375,7 +460,7 @@ class StorageContentContainer(Vertical):
375
460
  width: 100%;
376
461
  height: auto;
377
462
  border-bottom: solid $secondary;
378
- margin: 1 0;
463
+ margin: 1 0 0 0;
379
464
 
380
465
  PathSelector {
381
466
  &.primary_color {
@@ -524,13 +609,28 @@ class StorageContentContainer(Vertical):
524
609
  yield FileMetaLabel("Size", classes="file_size")
525
610
  yield FileMetaLabel("Date modified", classes="file_date")
526
611
  yield FileMetaLabel("Preview", classes="preview")
527
- with VerticalScroll(id="content"):
612
+ with ScrollVerticalContainerWithNoBindings(id="content", can_focus=False):
528
613
  for folder in self.storage_content.folders:
529
614
  yield FolderItem(
530
- self.storage, self.access_credentials_uuid, self.path, folder
615
+ self.storage,
616
+ self.access_credentials_uuid,
617
+ self.path,
618
+ folder,
619
+ self.focus_content,
531
620
  )
532
621
  for file in self.storage_content.files:
533
- yield FileItem(self.storage, self.path, file, id=file.uuid)
622
+ yield FileItem(
623
+ self.storage, self.path, file, self.focus_content, id=file.uuid
624
+ )
625
+
626
+ def focus(self, scroll_visible: bool = True) -> Self:
627
+ try:
628
+ content = self.query_one(ScrollVerticalContainerWithNoBindings)
629
+ except NoMatches:
630
+ return self
631
+ if len(content.children) > 0:
632
+ content.children[0].focus()
633
+ return self
534
634
 
535
635
  @on(Input.Submitted)
536
636
  def on_input_submitted(self, event: Input.Submitted):
@@ -545,19 +645,6 @@ class StorageContentContainer(Vertical):
545
645
  """
546
646
  self.apply_search_prefix(event.value)
547
647
 
548
- @on(Input.Blurred)
549
- def on_input_blurred(self, event: Input.Blurred):
550
- """
551
- Handle input blur events to apply the search prefix.
552
-
553
- This method is triggered when the input field loses focus and applies
554
- the search prefix to the current storage content.
555
-
556
- Args:
557
- event (Input.Blurred): The blur event containing the input value
558
- """
559
- self.apply_search_prefix(event.value)
560
-
561
648
  @on(FileItem.Preview)
562
649
  def on_file_item_preview(self, event: FileItem.Preview):
563
650
  """
@@ -711,5 +798,28 @@ class StorageContentContainer(Vertical):
711
798
  self.path,
712
799
  self.access_credentials_uuid,
713
800
  value,
801
+ focus_content=True,
802
+ )
803
+ )
804
+
805
+ def action_back_to_prev_path(self):
806
+ """
807
+ Navigate back to the previous path in the storage content.
808
+
809
+ This method updates the path to the parent directory and triggers a
810
+ SelectStorageItem message to refresh the storage content with the new path.
811
+ """
812
+ if not self.storage:
813
+ return
814
+ if not self.path:
815
+ return
816
+ path_parents = [i for i in self.path.split("/")[:-1] if i]
817
+ prev_path = "/".join(path_parents)
818
+ self.post_message(
819
+ SelectStorageItem(
820
+ self.storage,
821
+ prev_path,
822
+ self.access_credentials_uuid,
823
+ focus_content=True,
714
824
  )
715
825
  )