data-sourcerer 0.2.2__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 (44) hide show
  1. {data_sourcerer-0.2.2.dist-info → data_sourcerer-0.3.0.dist-info}/METADATA +1 -1
  2. {data_sourcerer-0.2.2.dist-info → data_sourcerer-0.3.0.dist-info}/RECORD +43 -29
  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 +7 -6
  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 +191 -85
  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/labeled_input.py +2 -2
  29. sourcerer/presentation/screens/shared/widgets/spinner.py +57 -0
  30. sourcerer/presentation/screens/storage_action_progress/main.py +7 -11
  31. sourcerer/presentation/screens/storages_list/__init__.py +0 -0
  32. sourcerer/presentation/screens/storages_list/main.py +180 -0
  33. sourcerer/presentation/screens/storages_list/messages/__init__.py +0 -0
  34. sourcerer/presentation/screens/storages_list/messages/reload_storages_request.py +8 -0
  35. sourcerer/presentation/screens/storages_list/styles.tcss +55 -0
  36. sourcerer/presentation/screens/storages_registration/__init__.py +0 -0
  37. sourcerer/presentation/screens/storages_registration/main.py +100 -0
  38. sourcerer/presentation/screens/storages_registration/styles.tcss +41 -0
  39. sourcerer/presentation/settings.py +29 -16
  40. sourcerer/presentation/utils.py +9 -1
  41. sourcerer/presentation/screens/shared/widgets/loader.py +0 -31
  42. {data_sourcerer-0.2.2.dist-info → data_sourcerer-0.3.0.dist-info}/WHEEL +0 -0
  43. {data_sourcerer-0.2.2.dist-info → data_sourcerer-0.3.0.dist-info}/entry_points.txt +0 -0
  44. {data_sourcerer-0.2.2.dist-info → data_sourcerer-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -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
 
@@ -332,18 +407,24 @@ class StorageContentContainer(Vertical):
332
407
  search_prefix: reactive[str | None] = reactive( # ty: ignore[invalid-assignment]
333
408
  None, recompose=False
334
409
  )
335
- access_credentials_uuid: reactive[
410
+ access_credentials_uuid: reactive[ # ty: ignore[invalid-assignment]
336
411
  str | None
337
- ] = reactive( # ty: ignore[invalid-assignment]
338
- "", recompose=False
339
- )
340
- storage_content: reactive[
412
+ ] = reactive("", recompose=False)
413
+ storage_content: reactive[ # ty: ignore[invalid-assignment]
341
414
  StorageContent | None
342
- ] = reactive( # ty: ignore[invalid-assignment]
343
- None, recompose=True
344
- )
415
+ ] = reactive(None, recompose=True)
345
416
  selected_files: reactive[set] = reactive(set(), recompose=False)
346
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
+ ]
347
428
 
348
429
  DEFAULT_CSS = """
349
430
 
@@ -379,7 +460,7 @@ class StorageContentContainer(Vertical):
379
460
  width: 100%;
380
461
  height: auto;
381
462
  border-bottom: solid $secondary;
382
- margin: 1 0;
463
+ margin: 1 0 0 0;
383
464
 
384
465
  PathSelector {
385
466
  &.primary_color {
@@ -528,13 +609,28 @@ class StorageContentContainer(Vertical):
528
609
  yield FileMetaLabel("Size", classes="file_size")
529
610
  yield FileMetaLabel("Date modified", classes="file_date")
530
611
  yield FileMetaLabel("Preview", classes="preview")
531
- with VerticalScroll(id="content"):
612
+ with ScrollVerticalContainerWithNoBindings(id="content", can_focus=False):
532
613
  for folder in self.storage_content.folders:
533
614
  yield FolderItem(
534
- 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,
535
620
  )
536
621
  for file in self.storage_content.files:
537
- 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
538
634
 
539
635
  @on(Input.Submitted)
540
636
  def on_input_submitted(self, event: Input.Submitted):
@@ -549,19 +645,6 @@ class StorageContentContainer(Vertical):
549
645
  """
550
646
  self.apply_search_prefix(event.value)
551
647
 
552
- @on(Input.Blurred)
553
- def on_input_blurred(self, event: Input.Blurred):
554
- """
555
- Handle input blur events to apply the search prefix.
556
-
557
- This method is triggered when the input field loses focus and applies
558
- the search prefix to the current storage content.
559
-
560
- Args:
561
- event (Input.Blurred): The blur event containing the input value
562
- """
563
- self.apply_search_prefix(event.value)
564
-
565
648
  @on(FileItem.Preview)
566
649
  def on_file_item_preview(self, event: FileItem.Preview):
567
650
  """
@@ -715,5 +798,28 @@ class StorageContentContainer(Vertical):
715
798
  self.path,
716
799
  self.access_credentials_uuid,
717
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,
718
824
  )
719
825
  )
@@ -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
+ )