data-sourcerer 0.2.3__py3-none-any.whl → 0.4.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 (51) hide show
  1. {data_sourcerer-0.2.3.dist-info → data_sourcerer-0.4.0.dist-info}/METADATA +3 -1
  2. {data_sourcerer-0.2.3.dist-info → data_sourcerer-0.4.0.dist-info}/RECORD +50 -34
  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/domain/storage_provider/entities.py +1 -1
  10. sourcerer/infrastructure/access_credentials/repositories.py +3 -2
  11. sourcerer/infrastructure/access_credentials/services.py +9 -25
  12. sourcerer/infrastructure/db/models.py +33 -2
  13. sourcerer/infrastructure/storage/__init__.py +0 -0
  14. sourcerer/infrastructure/storage/repositories.py +72 -0
  15. sourcerer/infrastructure/storage/services.py +37 -0
  16. sourcerer/infrastructure/storage_provider/services/azure.py +1 -3
  17. sourcerer/infrastructure/storage_provider/services/gcp.py +2 -3
  18. sourcerer/infrastructure/storage_provider/services/s3.py +1 -2
  19. sourcerer/infrastructure/utils.py +2 -1
  20. sourcerer/presentation/di_container.py +15 -0
  21. sourcerer/presentation/screens/file_system_finder/main.py +5 -10
  22. sourcerer/presentation/screens/file_system_finder/widgets/file_system_navigator.py +16 -13
  23. sourcerer/presentation/screens/main/main.py +89 -9
  24. sourcerer/presentation/screens/main/messages/preview_request.py +1 -0
  25. sourcerer/presentation/screens/main/messages/select_storage_item.py +1 -0
  26. sourcerer/presentation/screens/main/mixins/resize_containers_watcher_mixin.py +2 -1
  27. sourcerer/presentation/screens/main/widgets/storage_content.py +197 -80
  28. sourcerer/presentation/screens/main/widgets/storage_list_sidebar.py +99 -31
  29. sourcerer/presentation/screens/preview_content/main.py +216 -17
  30. sourcerer/presentation/screens/preview_content/styles.tcss +39 -4
  31. sourcerer/presentation/screens/preview_content/text_area_style.py +60 -0
  32. sourcerer/presentation/screens/provider_creds_list/main.py +38 -13
  33. sourcerer/presentation/screens/provider_creds_registration/main.py +10 -7
  34. sourcerer/presentation/screens/shared/modal_screens.py +37 -0
  35. sourcerer/presentation/screens/shared/widgets/spinner.py +57 -0
  36. sourcerer/presentation/screens/storage_action_progress/main.py +3 -5
  37. sourcerer/presentation/screens/storages_list/__init__.py +0 -0
  38. sourcerer/presentation/screens/storages_list/main.py +184 -0
  39. sourcerer/presentation/screens/storages_list/messages/__init__.py +0 -0
  40. sourcerer/presentation/screens/storages_list/messages/reload_storages_request.py +8 -0
  41. sourcerer/presentation/screens/storages_list/styles.tcss +55 -0
  42. sourcerer/presentation/screens/storages_registration/__init__.py +0 -0
  43. sourcerer/presentation/screens/storages_registration/main.py +100 -0
  44. sourcerer/presentation/screens/storages_registration/styles.tcss +41 -0
  45. sourcerer/presentation/settings.py +29 -16
  46. sourcerer/presentation/utils.py +9 -1
  47. sourcerer/settings.py +2 -0
  48. sourcerer/presentation/screens/shared/widgets/loader.py +0 -31
  49. {data_sourcerer-0.2.3.dist-info → data_sourcerer-0.4.0.dist-info}/WHEEL +0 -0
  50. {data_sourcerer-0.2.3.dist-info → data_sourcerer-0.4.0.dist-info}/entry_points.txt +0 -0
  51. {data_sourcerer-0.2.3.dist-info → data_sourcerer-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -7,18 +7,21 @@ 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
 
15
+ import humanize
13
16
  from textual import events, on
14
17
  from textual.app import ComposeResult
18
+ from textual.binding import Binding, BindingType
15
19
  from textual.containers import (
16
20
  Center,
17
21
  Container,
18
22
  Horizontal,
19
23
  Middle,
20
24
  Vertical,
21
- VerticalScroll,
22
25
  )
23
26
  from textual.css.query import NoMatches
24
27
  from textual.message import Message
@@ -38,8 +41,11 @@ from sourcerer.presentation.screens.main.messages.uncheck_files_request import (
38
41
  UncheckFilesRequest,
39
42
  )
40
43
  from sourcerer.presentation.screens.main.messages.upload_request import UploadRequest
44
+ from sourcerer.presentation.screens.shared.containers import (
45
+ ScrollVerticalContainerWithNoBindings,
46
+ )
41
47
  from sourcerer.presentation.screens.shared.widgets.button import Button
42
- from sourcerer.presentation.settings import NO_DATA_LOGO
48
+ from sourcerer.presentation.settings import NO_DATA_LOGO, KeyBindings
43
49
  from sourcerer.settings import (
44
50
  DIRECTORY_ICON,
45
51
  DOWNLOAD_ICON,
@@ -91,6 +97,10 @@ class ActionType(Enum):
91
97
  return action_map[action_str]
92
98
 
93
99
 
100
+ class UnfocusableCheckbox(Checkbox):
101
+ can_focus = False
102
+
103
+
94
104
  class FileMetaLabel(Static):
95
105
  """Widget for displaying file metadata information.
96
106
 
@@ -120,6 +130,16 @@ class PathSelector(Label):
120
130
  access_credentials_uuid: UUID of the access credentials being used
121
131
  """
122
132
 
133
+ can_focus = True
134
+
135
+ DEFAULT_CSS = """
136
+ PathSelector {
137
+ &:focus {
138
+ background: $secondary-lighten-2;
139
+ }
140
+ }
141
+ """
142
+
123
143
  def __init__(self, storage, path, access_credentials_uuid, *args, **kwargs):
124
144
  super().__init__(*args, **kwargs)
125
145
  self.storage = storage
@@ -128,31 +148,93 @@ class PathSelector(Label):
128
148
 
129
149
  def on_click(self, _: events.Click) -> None:
130
150
  """Handle click events to navigate to the selected path."""
151
+ self._select()
152
+
153
+ def on_key(self, event: events.Key) -> None:
154
+ """Handle key events to navigate to the selected path."""
155
+ if event.key == KeyBindings.ENTER.value:
156
+ self._select()
157
+
158
+ def _select(self):
159
+ """Select the current path."""
131
160
  self.post_message(
132
- SelectStorageItem(self.storage, self.path, self.access_credentials_uuid)
161
+ SelectStorageItem(
162
+ self.storage,
163
+ self.path,
164
+ self.access_credentials_uuid,
165
+ focus_content=True,
166
+ )
133
167
  )
134
168
 
135
169
 
136
- class FolderItem(Horizontal):
170
+ class StorageContentItem(Horizontal):
171
+ DEFAULT_CSS = """
172
+ StorageContentItem.active {
173
+ background: $secondary;
174
+ color: $panel;
175
+ }
176
+ StorageContentItem:focus {
177
+ background: $secondary-lighten-2;
178
+ color: $panel;
179
+ }
180
+ """
181
+
182
+ can_focus = True
183
+
184
+ def __init__(self, focus_first: bool, *args, **kwargs):
185
+ """Initialize the storage content widget."""
186
+ super().__init__(*args, **kwargs)
187
+ self.focus_first = focus_first
188
+
189
+ def on_mount(self) -> None:
190
+ """Handle the mounting of the widget."""
191
+ if self.focus_first and self.first_child:
192
+ self.focus()
193
+
194
+ @abstractmethod
195
+ def _select(self, widget=None):
196
+ raise NotImplementedError
197
+
198
+ def on_click(self, event: events.Click) -> None:
199
+ """Handle click events to navigate into the folder."""
200
+ self._select(event.widget)
201
+
202
+ def on_key(self, event: events.Key) -> None:
203
+ """Handle key events to navigate into the folder."""
204
+ if event.key == KeyBindings.ARROW_UP.value:
205
+ if self.first_child:
206
+ self.parent.children[-1].focus() # type: ignore
207
+ return
208
+ self.screen.focus_previous()
209
+ if event.key == KeyBindings.ARROW_DOWN.value:
210
+ if self.last_child:
211
+ self.parent.children[0].focus() # type: ignore
212
+ return
213
+ self.screen.focus_next()
214
+
215
+ @on(events.Enter)
216
+ @on(events.Leave)
217
+ def on_enter(self, _: events.Enter):
218
+ with contextlib.suppress(Exception):
219
+ self.set_class(self.is_mouse_over, "active")
220
+
221
+
222
+ class FolderItem(StorageContentItem):
137
223
  """Widget for displaying and interacting with folder items.
138
224
 
139
225
  This widget represents a folder in the storage content view, allowing
140
226
  navigation into the folder and visual feedback on hover/selection.
141
227
  """
142
228
 
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
229
  def __init__(
155
- self, storage, access_credentials_uuid, parent_path, folder, *args, **kwargs
230
+ self,
231
+ storage,
232
+ access_credentials_uuid,
233
+ parent_path,
234
+ folder,
235
+ focus_first,
236
+ *args,
237
+ **kwargs,
156
238
  ):
157
239
  """Initialize a folder item widget.
158
240
 
@@ -162,7 +244,7 @@ class FolderItem(Horizontal):
162
244
  parent_path: The parent path of the folder
163
245
  folder: The folder name
164
246
  """
165
- super().__init__(*args, **kwargs)
247
+ super().__init__(focus_first, *args, **kwargs)
166
248
  self.storage = storage
167
249
  self.access_credentials_uuid = access_credentials_uuid
168
250
  self.parent_path = parent_path
@@ -172,26 +254,29 @@ class FolderItem(Horizontal):
172
254
  """Compose the folder item layout with folder name and icon."""
173
255
  yield Label(f"{DIRECTORY_ICON}{self.folder.key}", markup=False)
174
256
 
175
- def on_click(self, _: events.Click) -> None:
176
- """Handle click events to navigate into the folder."""
257
+ def _select(self, widget=None):
258
+ """Select the folder."""
177
259
  path = self.folder.key
178
260
  if self.parent_path:
179
261
  path = self.parent_path.strip("/") + "/" + path
180
262
 
181
263
  self.post_message(
182
- SelectStorageItem(self.storage, path, self.access_credentials_uuid)
264
+ SelectStorageItem(
265
+ self.storage, path, self.access_credentials_uuid, focus_content=True
266
+ )
183
267
  )
184
268
 
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")
269
+ def on_key(self, event: events.Key) -> None:
270
+ """Handle key events to navigate into the folder."""
271
+ if event.key in (KeyBindings.ARROW_UP.value, KeyBindings.ARROW_DOWN.value):
272
+ event.prevent_default()
273
+ if event.key == KeyBindings.ENTER.value:
274
+ self._select()
275
+ return
276
+ super().on_key(event)
192
277
 
193
278
 
194
- class FileItem(Horizontal):
279
+ class FileItem(StorageContentItem):
195
280
  """Widget for displaying and interacting with file items.
196
281
 
197
282
  This widget represents a file in the storage content view, allowing
@@ -199,17 +284,10 @@ class FileItem(Horizontal):
199
284
  """
200
285
 
201
286
  DEFAULT_CSS = """
202
- FileItem {
203
- margin-bottom: 1;
204
- }
205
- FileItem.active {
206
- background: $secondary;
207
- color: $panel;
208
- }
209
287
  .file_size {
210
288
  color: $primary
211
289
  }
212
- Checkbox {
290
+ UnfocusableCheckbox {
213
291
  border: none;
214
292
  padding: 0 0;
215
293
  display: none;
@@ -219,7 +297,6 @@ class FileItem(Horizontal):
219
297
  }
220
298
  }
221
299
  """
222
- can_focus = False
223
300
 
224
301
  @dataclass
225
302
  class Selected(Message):
@@ -232,6 +309,7 @@ class FileItem(Horizontal):
232
309
  """Message sent when a file preview is selected."""
233
310
 
234
311
  name: str
312
+ size: int
235
313
 
236
314
  @dataclass
237
315
  class Unselect(Message):
@@ -239,11 +317,7 @@ class FileItem(Horizontal):
239
317
 
240
318
  name: str
241
319
 
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):
320
+ def __init__(self, storage, parent_path, file, focus_first, *args, **kwargs):
247
321
  """Initialize a file item widget.
248
322
 
249
323
  Args:
@@ -251,59 +325,64 @@ class FileItem(Horizontal):
251
325
  parent_path: The parent path of the file
252
326
  file: The file name
253
327
  """
254
- super().__init__(*args, **kwargs)
328
+ super().__init__(focus_first, *args, **kwargs)
255
329
  self.storage = storage
256
330
  self.parent_path = parent_path
257
331
  self.file = file
258
332
 
259
333
  def compose(self):
260
- yield Checkbox()
334
+ yield UnfocusableCheckbox()
261
335
  yield FileMetaLabel(
262
336
  f"{FILE_ICON} {self.file.key}", classes="file_name", markup=False
263
337
  )
264
- yield FileMetaLabel(f"{self.file.size}", classes="file_size", markup=False)
338
+ yield FileMetaLabel(
339
+ f"{humanize.naturalsize(self.file.size)}", classes="file_size", markup=False
340
+ )
265
341
  yield FileMetaLabel(
266
342
  str(self.file.date_modified), classes="file_date", markup=False
267
343
  )
268
344
  if self.file.is_text:
269
345
  yield Button(f"{PREVIEW_ICON}", name="preview", classes="download")
270
346
 
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")
347
+ def on_key(self, event: events.Key) -> None:
348
+ """Handle key events to toggle file selection."""
349
+ if event.key in (KeyBindings.ARROW_UP.value, KeyBindings.ARROW_DOWN.value):
350
+ event.prevent_default()
351
+ if event.key == KeyBindings.ENTER.value:
352
+ checkbox = self.query_one(UnfocusableCheckbox)
353
+ checkbox.value = not checkbox.value
354
+ if checkbox.value:
355
+ self.post_message(self.Selected(self.file.key))
356
+ else:
357
+ self.post_message(self.Unselect(self.file.key))
358
+ return
359
+ super().on_key(event)
278
360
 
279
- def on_click(self, event: events.Click) -> None:
280
- """Handle click events to toggle file selection."""
361
+ def _select(self, widget=None):
281
362
  preview_button = None
282
363
  with contextlib.suppress(NoMatches):
283
364
  preview_button = self.query_one(Button)
284
365
 
285
- if event.widget is preview_button:
286
- self.post_message(self.Preview(self.file.key))
366
+ if widget is preview_button:
367
+ self.post_message(self.Preview(self.file.key, self.file.size))
287
368
  return
288
369
 
289
- checkbox = self.query_one(Checkbox)
290
- if event.widget is not checkbox:
370
+ checkbox = self.query_one(UnfocusableCheckbox)
371
+ if widget is not checkbox:
291
372
  checkbox.value = not checkbox.value
292
373
  if checkbox.value:
293
374
  self.post_message(self.Selected(self.file.key))
294
375
  else:
295
376
  self.post_message(self.Unselect(self.file.key))
296
- event.prevent_default()
297
- event.stop()
298
377
 
299
378
  def uncheck(self):
300
379
  """Uncheck the file's checkbox."""
301
- checkbox = self.query_one(Checkbox)
380
+ checkbox = self.query_one(UnfocusableCheckbox)
302
381
  checkbox.value = False
303
382
 
304
383
  def check(self):
305
384
  """Check the file's checkbox."""
306
- checkbox = self.query_one(Checkbox)
385
+ checkbox = self.query_one(UnfocusableCheckbox)
307
386
  checkbox.value = True
308
387
 
309
388
 
@@ -340,6 +419,16 @@ class StorageContentContainer(Vertical):
340
419
  ] = reactive(None, recompose=True)
341
420
  selected_files: reactive[set] = reactive(set(), recompose=False)
342
421
  selected_files_n: reactive[int] = reactive(0, recompose=False)
422
+ focus_content: reactive[bool] = reactive(False, recompose=False)
423
+
424
+ BINDINGS: ClassVar[list[BindingType]] = [
425
+ Binding(
426
+ f"{KeyBindings.CTRL.value}+{KeyBindings.BACKSPACE.value}",
427
+ "back_to_prev_path",
428
+ "Navigate back to the previous path",
429
+ show=True,
430
+ ),
431
+ ]
343
432
 
344
433
  DEFAULT_CSS = """
345
434
 
@@ -375,7 +464,7 @@ class StorageContentContainer(Vertical):
375
464
  width: 100%;
376
465
  height: auto;
377
466
  border-bottom: solid $secondary;
378
- margin: 1 0;
467
+ margin: 1 0 0 0;
379
468
 
380
469
  PathSelector {
381
470
  &.primary_color {
@@ -476,6 +565,8 @@ class StorageContentContainer(Vertical):
476
565
  }
477
566
  """
478
567
 
568
+ search_input_id: ClassVar[str] = "search_input"
569
+
479
570
  def compose(self) -> ComposeResult:
480
571
  if not self.storage:
481
572
  return
@@ -499,7 +590,7 @@ class StorageContentContainer(Vertical):
499
590
  with Horizontal():
500
591
  yield Label("Search:")
501
592
  yield Input(
502
- id="search_input",
593
+ id=self.search_input_id,
503
594
  placeholder="input path prefix here...",
504
595
  value=self.search_prefix,
505
596
  )
@@ -524,13 +615,28 @@ class StorageContentContainer(Vertical):
524
615
  yield FileMetaLabel("Size", classes="file_size")
525
616
  yield FileMetaLabel("Date modified", classes="file_date")
526
617
  yield FileMetaLabel("Preview", classes="preview")
527
- with VerticalScroll(id="content"):
618
+ with ScrollVerticalContainerWithNoBindings(id="content", can_focus=False):
528
619
  for folder in self.storage_content.folders:
529
620
  yield FolderItem(
530
- self.storage, self.access_credentials_uuid, self.path, folder
621
+ self.storage,
622
+ self.access_credentials_uuid,
623
+ self.path,
624
+ folder,
625
+ self.focus_content,
531
626
  )
532
627
  for file in self.storage_content.files:
533
- yield FileItem(self.storage, self.path, file, id=file.uuid)
628
+ yield FileItem(
629
+ self.storage, self.path, file, self.focus_content, id=file.uuid
630
+ )
631
+
632
+ def focus(self, scroll_visible: bool = True) -> Self:
633
+ try:
634
+ content = self.query_one(ScrollVerticalContainerWithNoBindings)
635
+ except NoMatches:
636
+ return self
637
+ if len(content.children) > 0:
638
+ content.children[0].focus()
639
+ return self
534
640
 
535
641
  @on(Input.Submitted)
536
642
  def on_input_submitted(self, event: Input.Submitted):
@@ -545,19 +651,6 @@ class StorageContentContainer(Vertical):
545
651
  """
546
652
  self.apply_search_prefix(event.value)
547
653
 
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
654
  @on(FileItem.Preview)
562
655
  def on_file_item_preview(self, event: FileItem.Preview):
563
656
  """
@@ -576,6 +669,7 @@ class StorageContentContainer(Vertical):
576
669
  self.storage,
577
670
  self.access_credentials_uuid,
578
671
  os.path.join(self.path, event.name) if self.path else event.name,
672
+ event.size,
579
673
  )
580
674
  )
581
675
 
@@ -711,5 +805,28 @@ class StorageContentContainer(Vertical):
711
805
  self.path,
712
806
  self.access_credentials_uuid,
713
807
  value,
808
+ focus_content=True,
809
+ )
810
+ )
811
+
812
+ def action_back_to_prev_path(self):
813
+ """
814
+ Navigate back to the previous path in the storage content.
815
+
816
+ This method updates the path to the parent directory and triggers a
817
+ SelectStorageItem message to refresh the storage content with the new path.
818
+ """
819
+ if not self.storage:
820
+ return
821
+ if not self.path:
822
+ return
823
+ path_parents = [i for i in self.path.split("/")[:-1] if i]
824
+ prev_path = "/".join(path_parents)
825
+ self.post_message(
826
+ SelectStorageItem(
827
+ self.storage,
828
+ prev_path,
829
+ self.access_credentials_uuid,
830
+ focus_content=True,
714
831
  )
715
832
  )
@@ -7,10 +7,12 @@ and selection of storage items.
7
7
 
8
8
  from collections import namedtuple
9
9
  from itertools import groupby
10
+ from typing import Self
10
11
 
11
12
  from textual import events, on
12
13
  from textual.app import ComposeResult
13
- from textual.containers import Container, Horizontal, VerticalScroll
14
+ from textual.containers import Horizontal, Vertical
15
+ from textual.css.query import NoMatches
14
16
  from textual.reactive import reactive
15
17
  from textual.widgets import Label, Rule
16
18
 
@@ -23,8 +25,12 @@ from sourcerer.presentation.screens.main.messages.select_storage_item import (
23
25
  SelectStorageItem,
24
26
  )
25
27
  from sourcerer.presentation.screens.main.widgets.gradient import GradientWidget
28
+ from sourcerer.presentation.screens.shared.containers import (
29
+ ScrollVerticalContainerWithNoBindings,
30
+ )
26
31
  from sourcerer.presentation.screens.shared.widgets.button import Button
27
- from sourcerer.presentation.screens.shared.widgets.loader import Loader
32
+ from sourcerer.presentation.screens.shared.widgets.spinner import Spinner
33
+ from sourcerer.presentation.settings import KeyBindings
28
34
 
29
35
  STORAGE_ICONS = {
30
36
  StorageProvider.S3: "🟠",
@@ -41,6 +47,9 @@ class StorageItem(Label):
41
47
  selection and visual feedback on hover.
42
48
  """
43
49
 
50
+ can_focus = True
51
+ selected = reactive(False, recompose=True, toggle_class="selected")
52
+
44
53
  DEFAULT_CSS = """
45
54
  StorageItem {
46
55
  width: 90%;
@@ -51,7 +60,17 @@ class StorageItem(Label):
51
60
  text-wrap: nowrap;
52
61
 
53
62
  & > :hover {
54
- background: $warning;
63
+ background: $primary-lighten-2;
64
+ color: $panel;
65
+ }
66
+
67
+ & > :focus {
68
+ background: $primary-lighten-2;
69
+ color: $panel;
70
+ }
71
+
72
+ &.selected {
73
+ background: $primary;
55
74
  color: $panel;
56
75
  }
57
76
  }
@@ -71,6 +90,38 @@ class StorageItem(Label):
71
90
 
72
91
  def on_click(self, _: events.Click) -> None:
73
92
  """Handle click events to select the storage item."""
93
+ self._select_storage()
94
+
95
+ def on_key(self, event: events.Key) -> None:
96
+ """Handle key events to select the storage item."""
97
+ if event.key == KeyBindings.ENTER.value:
98
+ self._select_storage()
99
+ return
100
+ storages = [
101
+ component
102
+ for component in self.screen.focus_chain
103
+ if isinstance(component, StorageItem)
104
+ ]
105
+ if not storages:
106
+ return
107
+ if event.key == KeyBindings.ARROW_DOWN.value:
108
+ if self.screen.focused == storages[-1]:
109
+ storages[0].focus()
110
+ return
111
+ self.screen.focus_next(StorageItem)
112
+ elif event.key == KeyBindings.ARROW_UP.value:
113
+ if self.screen.focused == storages[0]:
114
+ storages[-1].focus()
115
+ return
116
+ self.screen.focus_previous(StorageItem)
117
+
118
+ def _select_storage(self):
119
+ """
120
+ Select the storage item and notify the application.
121
+ This method posts a message to select the storage item based on its
122
+ name and access credentials UUID.
123
+
124
+ """
74
125
  self.post_message(
75
126
  SelectStorageItem(
76
127
  self.storage_name, access_credentials_uuid=self.access_credentials_uuid
@@ -78,7 +129,7 @@ class StorageItem(Label):
78
129
  )
79
130
 
80
131
 
81
- class StorageListSidebar(VerticalScroll):
132
+ class StorageListSidebar(Vertical):
82
133
  """Sidebar widget for displaying the list of storage providers.
83
134
 
84
135
  This widget manages the display of storage providers grouped by their type,
@@ -98,27 +149,29 @@ class StorageListSidebar(VerticalScroll):
98
149
  StorageListSidebar {
99
150
  padding-right: 0;
100
151
  margin-right: 0;
101
- & > .storage-group {
152
+ height: 100%;
153
+ margin-bottom: 1;
154
+ #rule-left {
155
+ width: 1;
156
+ }
157
+
158
+ ScrollVerticalContainerWithNoBindings{
159
+ height: 95%;
160
+ }
161
+
162
+ Horizontal {
102
163
  height: auto;
103
- margin-bottom: 1;
104
- #rule-left {
105
- width: 1;
106
- }
107
-
108
- Horizontal {
109
- height: auto;
110
- }
111
- Rule.-horizontal {
112
- height: 1;
113
- margin: 0 0;
114
-
115
- }
116
- .storage-letter {
117
- color: $secondary;
118
- padding: 0 1;
119
- }
164
+ }
165
+ Rule.-horizontal {
166
+ height: 1;
167
+ margin: 0 0;
120
168
 
121
169
  }
170
+ .storage-letter {
171
+ color: $secondary;
172
+ padding: 0 1;
173
+ }
174
+
122
175
  }
123
176
  #header {
124
177
  width: 100%;
@@ -127,7 +180,7 @@ class StorageListSidebar(VerticalScroll):
127
180
  width: auto;
128
181
  }
129
182
 
130
- Loader {
183
+ Spinner {
131
184
  width: 5%;
132
185
  }
133
186
  }
@@ -136,7 +189,7 @@ class StorageListSidebar(VerticalScroll):
136
189
  def compose(self) -> ComposeResult:
137
190
  with Horizontal(id="header"):
138
191
  if self.is_loading:
139
- yield Loader()
192
+ yield Spinner()
140
193
  yield GradientWidget(
141
194
  " SOURCERER" if self.is_loading else "🧙SOURCERER",
142
195
  id="left-middle",
@@ -150,11 +203,11 @@ class StorageListSidebar(VerticalScroll):
150
203
  for storage in storages
151
204
  ]
152
205
  storages = sorted(storages, key=lambda x: x.storage.storage)
206
+ with ScrollVerticalContainerWithNoBindings():
207
+ for letter, storages_group in groupby(
208
+ storages, key=lambda x: x.storage.storage[0]
209
+ ):
153
210
 
154
- for letter, storages_group in groupby(
155
- storages, key=lambda x: x.storage.storage[0]
156
- ):
157
- with Container(id=f"group-{letter}", classes="storage-group"):
158
211
  yield Horizontal(
159
212
  Rule(id="rule-left"),
160
213
  Label(letter.upper(), classes="storage-letter"),
@@ -163,15 +216,30 @@ class StorageListSidebar(VerticalScroll):
163
216
 
164
217
  for item in storages_group:
165
218
  yield StorageItem(
166
- renderable=STORAGE_ICONS.get(item.storage.provider, "")
167
- + " "
168
- + item.storage.storage,
219
+ renderable=f'{STORAGE_ICONS.get(item.storage.provider, "")} {item.storage.storage}',
169
220
  storage_name=item.storage.storage,
170
221
  access_credentials_uuid=item.access_credentials_uuid,
171
222
  )
172
223
 
224
+ def focus(self, scroll_visible: bool = True) -> Self:
225
+ try:
226
+ content = self.query_one(StorageItem)
227
+ except NoMatches:
228
+ return self
229
+ content.focus()
230
+ return self
231
+
173
232
  @on(Button.Click)
174
233
  def on_button_click(self, event: Button.Click) -> None:
175
234
  """Handle button click events to refresh the storage list."""
176
235
  if event.action == "header_click":
177
236
  self.post_message(RefreshStoragesListRequest())
237
+
238
+ @on(SelectStorageItem)
239
+ def on_select_storage_item(self, event: SelectStorageItem) -> None:
240
+ """Handle selection of a storage item."""
241
+ for child in self.query(StorageItem):
242
+ child.selected = (
243
+ child.storage_name == event.name
244
+ and child.access_credentials_uuid == event.access_credentials_uuid
245
+ )