data-sourcerer 0.3.0__py3-none-any.whl → 0.5.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 (55) hide show
  1. {data_sourcerer-0.3.0.dist-info → data_sourcerer-0.5.0.dist-info}/METADATA +11 -8
  2. {data_sourcerer-0.3.0.dist-info → data_sourcerer-0.5.0.dist-info}/RECORD +55 -34
  3. sourcerer/__init__.py +3 -1
  4. sourcerer/domain/package_meta/__init__.py +0 -0
  5. sourcerer/domain/package_meta/entities.py +9 -0
  6. sourcerer/domain/package_meta/services.py +9 -0
  7. sourcerer/domain/settings/__init__.py +0 -0
  8. sourcerer/domain/settings/entities.py +11 -0
  9. sourcerer/domain/settings/repositories.py +20 -0
  10. sourcerer/domain/settings/services.py +19 -0
  11. sourcerer/domain/storage_provider/entities.py +1 -1
  12. sourcerer/infrastructure/db/models.py +23 -0
  13. sourcerer/infrastructure/package_meta/__init__.py +0 -0
  14. sourcerer/infrastructure/package_meta/services.py +26 -0
  15. sourcerer/infrastructure/settings/__init__.py +0 -0
  16. sourcerer/infrastructure/settings/repositories.py +59 -0
  17. sourcerer/infrastructure/settings/services.py +16 -0
  18. sourcerer/infrastructure/storage_provider/services/azure.py +1 -3
  19. sourcerer/infrastructure/storage_provider/services/gcp.py +1 -3
  20. sourcerer/infrastructure/storage_provider/services/s3.py +1 -2
  21. sourcerer/infrastructure/utils.py +1 -0
  22. sourcerer/presentation/di_container.py +13 -0
  23. sourcerer/presentation/screens/about/__init__.py +0 -0
  24. sourcerer/presentation/screens/about/main.py +60 -0
  25. sourcerer/presentation/screens/about/styles.tcss +32 -0
  26. sourcerer/presentation/screens/file_system_finder/__init__.py +0 -0
  27. sourcerer/presentation/screens/file_system_finder/main.py +3 -9
  28. sourcerer/presentation/screens/main/main.py +116 -8
  29. sourcerer/presentation/screens/main/messages/preview_request.py +1 -0
  30. sourcerer/presentation/screens/main/styles.tcss +13 -4
  31. sourcerer/presentation/screens/main/widgets/storage_content.py +10 -3
  32. sourcerer/presentation/screens/main/widgets/storage_list_sidebar.py +102 -18
  33. sourcerer/presentation/screens/preview_content/main.py +202 -15
  34. sourcerer/presentation/screens/preview_content/styles.tcss +39 -4
  35. sourcerer/presentation/screens/preview_content/text_area_style.py +60 -0
  36. sourcerer/presentation/screens/provider_creds_list/main.py +23 -9
  37. sourcerer/presentation/screens/provider_creds_list/styles.tcss +9 -0
  38. sourcerer/presentation/screens/provider_creds_registration/main.py +3 -3
  39. sourcerer/presentation/screens/settings/__init__.py +0 -0
  40. sourcerer/presentation/screens/settings/main.py +70 -0
  41. sourcerer/presentation/screens/settings/styles.tcss +44 -0
  42. sourcerer/presentation/screens/shared/modal_screens.py +37 -0
  43. sourcerer/presentation/screens/shared/widgets/button.py +11 -0
  44. sourcerer/presentation/screens/shared/widgets/labeled_input.py +1 -3
  45. sourcerer/presentation/screens/storage_action_progress/main.py +1 -2
  46. sourcerer/presentation/screens/storages_list/main.py +24 -9
  47. sourcerer/presentation/screens/storages_list/styles.tcss +7 -0
  48. sourcerer/presentation/screens/storages_registration/main.py +3 -3
  49. sourcerer/presentation/settings.py +1 -0
  50. sourcerer/presentation/utils.py +1 -0
  51. sourcerer/settings.py +2 -0
  52. sourcerer/utils.py +19 -1
  53. {data_sourcerer-0.3.0.dist-info → data_sourcerer-0.5.0.dist-info}/WHEEL +0 -0
  54. {data_sourcerer-0.3.0.dist-info → data_sourcerer-0.5.0.dist-info}/entry_points.txt +0 -0
  55. {data_sourcerer-0.3.0.dist-info → data_sourcerer-0.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,32 @@
1
+ AboutScreen {
2
+ align: center middle;
3
+ content-align: center top;
4
+
5
+ & > Container {
6
+ padding: 1 2 0 2;
7
+ margin: 0 0;
8
+ width: 50;
9
+ height: 10;
10
+ border: solid $border;
11
+
12
+ & > Static {
13
+ text-align: center;
14
+ text-wrap: wrap;
15
+ }
16
+
17
+ & > Horizontal#controls {
18
+ padding-top: 1;
19
+ align: center bottom;
20
+
21
+ & > Button {
22
+ color: $border;
23
+ border: none;
24
+
25
+ & > :focus {
26
+ color: $border;
27
+ }
28
+ }
29
+ }
30
+ }
31
+
32
+ }
@@ -1,16 +1,13 @@
1
1
  from collections.abc import Callable
2
2
  from dataclasses import dataclass
3
3
  from pathlib import Path
4
- from typing import ClassVar
5
4
 
6
5
  from dependency_injector.wiring import Provide
7
6
  from textual import on
8
7
  from textual.app import ComposeResult
9
- from textual.binding import Binding, BindingType
10
8
  from textual.containers import Container, Horizontal
11
9
  from textual.css.query import NoMatches
12
10
  from textual.reactive import reactive
13
- from textual.screen import ModalScreen
14
11
  from textual.widgets import Static
15
12
 
16
13
  from sourcerer.infrastructure.file_system.services import FileSystemService
@@ -18,6 +15,7 @@ from sourcerer.presentation.di_container import DiContainer
18
15
  from sourcerer.presentation.screens.file_system_finder.widgets.file_system_navigator import (
19
16
  FileSystemNavigator,
20
17
  )
18
+ from sourcerer.presentation.screens.shared.modal_screens import ExitBoundModalScreen
21
19
  from sourcerer.presentation.screens.shared.widgets.button import Button
22
20
 
23
21
 
@@ -27,14 +25,10 @@ class FileSystemSelectionValidationRule:
27
25
  error_message: str
28
26
 
29
27
 
30
- class FileSystemNavigationModal(ModalScreen):
28
+ class FileSystemNavigationModal(ExitBoundModalScreen):
31
29
  CONTAINER_ID = "file_system_view_container"
32
30
  CSS_PATH = "styles.tcss"
33
31
 
34
- BINDINGS: ClassVar[list[BindingType]] = [
35
- Binding("escape", "app.pop_screen", "Pop screen"),
36
- ]
37
-
38
32
  active_path: reactive[Path] = reactive(Path())
39
33
 
40
34
  def __init__(
@@ -126,7 +120,7 @@ class FileSystemNavigationModal(ModalScreen):
126
120
  event (Button.Click): The event containing the button that was clicked.
127
121
  """
128
122
  if event.action == "close":
129
- self.on_close()
123
+ self.action_cancel_screen()
130
124
  else:
131
125
  self.on_apply()
132
126
 
@@ -1,24 +1,32 @@
1
+ import contextlib
1
2
  import time
2
3
  import traceback
4
+ from collections.abc import Iterable
3
5
  from concurrent.futures import ThreadPoolExecutor
4
6
  from pathlib import Path
5
7
  from typing import ClassVar
6
8
 
7
9
  from dependency_injector.wiring import Provide
8
10
  from textual import on, work
9
- from textual.app import App, ComposeResult
11
+ from textual.app import App, ComposeResult, SystemCommand
10
12
  from textual.binding import Binding, BindingType
11
13
  from textual.containers import Horizontal
14
+ from textual.css.query import NoMatches
12
15
  from textual.reactive import reactive
16
+ from textual.screen import Screen
13
17
  from textual.widgets import Footer
14
18
 
19
+ from sourcerer.domain.package_meta.services import BasePackageMetaService
20
+ from sourcerer.domain.settings.entities import SettingsFields
15
21
  from sourcerer.domain.storage_provider.entities import Storage
16
22
  from sourcerer.infrastructure.access_credentials.services import CredentialsService
23
+ from sourcerer.infrastructure.settings.services import SettingsService
17
24
  from sourcerer.infrastructure.storage_provider.exceptions import (
18
25
  ListStorageItemsError,
19
26
  )
20
27
  from sourcerer.infrastructure.utils import generate_uuid
21
28
  from sourcerer.presentation.di_container import DiContainer
29
+ from sourcerer.presentation.screens.about.main import AboutScreen
22
30
  from sourcerer.presentation.screens.critical_error.main import CriticalErrorScreen
23
31
  from sourcerer.presentation.screens.file_system_finder.main import (
24
32
  FileSystemNavigationModal,
@@ -53,6 +61,7 @@ from sourcerer.presentation.screens.preview_content.main import PreviewContentSc
53
61
  from sourcerer.presentation.screens.provider_creds_list.main import (
54
62
  ProviderCredsListScreen,
55
63
  )
64
+ from sourcerer.presentation.screens.settings.main import SettingsScreen
56
65
  from sourcerer.presentation.screens.storage_action_progress.main import (
57
66
  DeleteKey,
58
67
  DownloadKey,
@@ -101,7 +110,10 @@ class Sourcerer(App, ResizeContainersWatcherMixin):
101
110
  CSS_PATH = "styles.tcss"
102
111
  BINDINGS: ClassVar[list[BindingType]] = [
103
112
  Binding("ctrl+r", "registrations", "Registrations list"),
104
- Binding("ctrl+s", "storages", "Storages list"),
113
+ Binding("ctrl+l", "storages", "Storages list"),
114
+ Binding("ctrl+f", "find", show=False),
115
+ Binding("ctrl+s", "settings", "Settings"),
116
+ Binding("ctrl+a", "about", "About"),
105
117
  Binding(
106
118
  KeyBindings.ARROW_LEFT.value, "focus_sidebar", "Focus sidebar", show=False
107
119
  ),
@@ -113,15 +125,24 @@ class Sourcerer(App, ResizeContainersWatcherMixin):
113
125
 
114
126
  def __init__(
115
127
  self,
128
+ settings_service: SettingsService = Provide[DiContainer.settings_service],
116
129
  credentials_service: CredentialsService = Provide[
117
130
  DiContainer.credentials_service
118
131
  ],
132
+ package_meta_service: BasePackageMetaService = Provide[
133
+ DiContainer.package_meta_service
134
+ ],
119
135
  *args,
120
136
  **kwargs,
121
137
  ):
122
138
  super().__init__(*args, **kwargs)
139
+ self.settings_service = settings_service
123
140
  self.credentials_service = credentials_service
124
- self.storage_list_sidebar = StorageListSidebar(id="storage_list_sidebar")
141
+ self.package_meta_service = package_meta_service
142
+ self.settings = self.settings_service.load_settings()
143
+ self.storage_list_sidebar = StorageListSidebar(
144
+ self.settings.group_by_access_credentials, id="storage_list_sidebar"
145
+ )
125
146
  self.storage_content = StorageContentContainer(id="storage_content_container")
126
147
  self.load_percentage = 0
127
148
  self.active_resizing_rule: ResizingRule | None = None
@@ -142,16 +163,58 @@ class Sourcerer(App, ResizeContainersWatcherMixin):
142
163
  def _handle_exception(self, error: Exception) -> None:
143
164
  self.push_screen(CriticalErrorScreen(str(error), traceback.format_exc()))
144
165
 
166
+ def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
167
+ yield SystemCommand(
168
+ "Quit the application",
169
+ "Quit the application as soon as possible",
170
+ self.action_quit,
171
+ )
172
+
173
+ if screen.query("HelpPanel"):
174
+ yield SystemCommand(
175
+ "Hide keys and help panel",
176
+ "Hide the keys and widget help panel",
177
+ self.action_hide_help_panel,
178
+ )
179
+ else:
180
+ yield SystemCommand(
181
+ "Show keys and help panel",
182
+ "Show help for the focused widget and a summary of available keys",
183
+ self.action_show_help_panel,
184
+ )
185
+ yield SystemCommand(
186
+ "Save screenshot",
187
+ "Save an SVG 'screenshot' of the current screen",
188
+ self.deliver_screenshot,
189
+ )
190
+ yield SystemCommand("About", "About sourcerer", self.action_about)
191
+ yield SystemCommand("Settings", "Sourcerer settings", self.action_settings)
192
+
145
193
  def on_mount(self):
146
194
  """
147
195
  Initializes the application theme and storage list on mount.
148
196
  """
149
197
 
150
198
  self.register_theme(github_dark_theme) # pyright: ignore [reportArgumentType]
151
-
152
- self.theme = "github-dark"
199
+ if self.settings.theme in self._registered_themes:
200
+ self.theme = self.settings.theme
201
+
202
+ package_meta = self.package_meta_service.get_package_meta()
203
+ if package_meta.has_available_update:
204
+ self.notify(
205
+ f"Sourcerer {package_meta.version} "
206
+ f"is running while {package_meta.latest_version} is available",
207
+ severity="warning",
208
+ )
153
209
  self.init_storages_list()
154
210
 
211
+ def action_find(self):
212
+ """
213
+ Focus search input.
214
+ """
215
+ with contextlib.suppress(NoMatches):
216
+ self.query_one(f"#{self.storage_content.search_input_id}").focus()
217
+
155
218
  def action_focus_content(self):
156
219
  """
157
220
  Focuses the storage content container.
@@ -174,10 +237,53 @@ class Sourcerer(App, ResizeContainersWatcherMixin):
174
237
  This method is typically used to allow users to add their
175
238
  cloud storage credentials, which will then be reflected in the storage
176
239
  """
177
- self.app.push_screen(ProviderCredsListScreen(), callback=self.refresh_storages)
240
+ self.app.push_screen(
241
+ ProviderCredsListScreen(), callback=self.modal_screen_callback
242
+ )
243
+
244
+ def action_settings(self):
245
+ """
246
+ Opens the settings screen.
247
+ This method is triggered by the key binding "ctrl+s" and allows the user
248
+ to modify application settings such as theme and grouping of storage items.
249
+ It retrieves the current settings from the settings service and pushes
250
+ the SettingsScreen to the application stack. A callback is set to handle
251
+ the settings changes when the screen is closed.
252
+ """
253
+ settings = self.settings_service.load_settings()
254
+ self.app.push_screen(SettingsScreen(settings), callback=self.settings_callback)
178
255
 
179
256
  def action_storages(self):
180
- self.app.push_screen(StoragesListScreen(), callback=self.refresh_storages)
257
+ self.app.push_screen(StoragesListScreen(), callback=self.modal_screen_callback)
258
+
259
+ def action_about(self):
260
+ self.push_screen(AboutScreen())
261
+
262
+ def settings_callback(self, settings: dict | None):
263
+ default_settings = self.settings_service.load_settings()
264
+ if settings is None:
265
+ self.app.theme = default_settings.theme
266
+ return
267
+ self.app.theme = settings[SettingsFields.theme]
268
+ if (theme := settings.get(SettingsFields.theme)) != default_settings.theme:
269
+ self.settings_service.set_setting(SettingsFields.theme, theme) # type: ignore
270
+
271
+ if (
272
+ group_by := settings.get(SettingsFields.group_by_access_credentials)
273
+ ) != default_settings.group_by_access_credentials:
274
+ self.settings_service.set_setting(SettingsFields.group_by_access_credentials, group_by) # type: ignore
275
+ self.storage_list_sidebar.groupby_access_credentials = group_by # type: ignore
276
+
277
+ def modal_screen_callback(self, requires_storage_refresh: bool | None = True):
278
+ """
279
+ Callback for modal screens to refresh the storage list if required.
280
+
281
+ This method is called when a modal screen is closed. If the
282
+ `requires_storage_refresh` flag is set to True, it refreshes the
283
+ storage list by calling the `refresh_storages` method.
284
+ """
285
+ if requires_storage_refresh:
286
+ self.refresh_storages()
181
287
 
182
288
  def refresh_storages(self, *args, **kwargs):
183
289
  """
@@ -187,6 +293,7 @@ class Sourcerer(App, ResizeContainersWatcherMixin):
187
293
  configurations.
188
294
  """
189
295
  self.storage_list_sidebar.storages = {}
296
+ self.storage_list_sidebar.last_update_timestamp = time.time()
190
297
  self.init_storages_list()
191
298
 
192
299
  @work(thread=True)
@@ -384,6 +491,7 @@ class Sourcerer(App, ResizeContainersWatcherMixin):
384
491
  PreviewContentScreen(
385
492
  storage_name=event.storage_name,
386
493
  key=event.path,
494
+ file_size=event.size,
387
495
  access_credentials_uuid=event.access_credentials_uuid,
388
496
  )
389
497
  )
@@ -562,7 +670,7 @@ class Sourcerer(App, ResizeContainersWatcherMixin):
562
670
  for storage in credentials.storages
563
671
  if storage.name not in storage_names
564
672
  ]
565
- self.storage_list_sidebar.storages[credentials.uuid] = (
673
+ self.storage_list_sidebar.storages[(credentials.uuid, credentials.name)] = (
566
674
  storages + registered_storages
567
675
  )
568
676
  self.storage_list_sidebar.last_update_timestamp = time.time()
@@ -8,3 +8,4 @@ class PreviewRequest(Message):
8
8
  storage_name: str
9
9
  access_credentials_uuid: str
10
10
  path: str
11
+ size: int
@@ -12,9 +12,18 @@ StorageContent {
12
12
  align-horizontal: center;
13
13
 
14
14
  & > Button {
15
+ color: $primary;
15
16
  border: solid $primary;
16
- width: 10;
17
- content-align-horizontal: center;
17
+ padding-top: 0;
18
+ width: 15;
19
+ text-align: center;
20
+
21
+ &:hover {
22
+ color: $text;
23
+ }
24
+ &:focus {
25
+ color: $text;
26
+ }
18
27
  }
19
28
  }
20
29
 
@@ -25,8 +34,8 @@ StorageContent {
25
34
  padding: 1 1 0 1;
26
35
  }
27
36
 
28
- Rule {
37
+ ResizingRule {
29
38
  color: $background-lighten-3;
30
39
  padding: 0 0;
31
40
  margin: 0 0;
32
- }
41
+ }
@@ -12,6 +12,7 @@ from dataclasses import dataclass
12
12
  from enum import Enum, auto
13
13
  from typing import ClassVar, Self
14
14
 
15
+ import humanize
15
16
  from textual import events, on
16
17
  from textual.app import ComposeResult
17
18
  from textual.binding import Binding, BindingType
@@ -308,6 +309,7 @@ class FileItem(StorageContentItem):
308
309
  """Message sent when a file preview is selected."""
309
310
 
310
311
  name: str
312
+ size: int
311
313
 
312
314
  @dataclass
313
315
  class Unselect(Message):
@@ -333,7 +335,9 @@ class FileItem(StorageContentItem):
333
335
  yield FileMetaLabel(
334
336
  f"{FILE_ICON} {self.file.key}", classes="file_name", markup=False
335
337
  )
336
- 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
+ )
337
341
  yield FileMetaLabel(
338
342
  str(self.file.date_modified), classes="file_date", markup=False
339
343
  )
@@ -360,7 +364,7 @@ class FileItem(StorageContentItem):
360
364
  preview_button = self.query_one(Button)
361
365
 
362
366
  if widget is preview_button:
363
- self.post_message(self.Preview(self.file.key))
367
+ self.post_message(self.Preview(self.file.key, self.file.size))
364
368
  return
365
369
 
366
370
  checkbox = self.query_one(UnfocusableCheckbox)
@@ -561,6 +565,8 @@ class StorageContentContainer(Vertical):
561
565
  }
562
566
  """
563
567
 
568
+ search_input_id: ClassVar[str] = "search_input"
569
+
564
570
  def compose(self) -> ComposeResult:
565
571
  if not self.storage:
566
572
  return
@@ -584,7 +590,7 @@ class StorageContentContainer(Vertical):
584
590
  with Horizontal():
585
591
  yield Label("Search:")
586
592
  yield Input(
587
- id="search_input",
593
+ id=self.search_input_id,
588
594
  placeholder="input path prefix here...",
589
595
  value=self.search_prefix,
590
596
  )
@@ -663,6 +669,7 @@ class StorageContentContainer(Vertical):
663
669
  self.storage,
664
670
  self.access_credentials_uuid,
665
671
  os.path.join(self.path, event.name) if self.path else event.name,
672
+ event.size,
666
673
  )
667
674
  )
668
675
 
@@ -32,12 +32,14 @@ from sourcerer.presentation.screens.shared.widgets.button import Button
32
32
  from sourcerer.presentation.screens.shared.widgets.spinner import Spinner
33
33
  from sourcerer.presentation.settings import KeyBindings
34
34
 
35
+ """Mapping of storage provider types to their display icons."""
35
36
  STORAGE_ICONS = {
36
37
  StorageProvider.S3: "🟠",
37
38
  StorageProvider.GoogleCloudStorage: "🔵",
38
39
  StorageProvider.AzureStorage: "⚪️",
39
40
  }
40
- """Mapping of storage provider types to their display icons."""
41
+
42
+ StorageData = namedtuple("Storage", ["access_credentials_uuid", "storage"])
41
43
 
42
44
 
43
45
  class StorageItem(Label):
@@ -140,7 +142,8 @@ class StorageListSidebar(Vertical):
140
142
  """
141
143
 
142
144
  is_loading: reactive[bool] = reactive(False, recompose=True)
143
- storages: reactive[dict[str, list[Storage]]] = reactive({})
145
+ groupby_access_credentials: reactive[bool] = reactive(False, recompose=True)
146
+ storages: reactive[dict[tuple[str, str], list[Storage]]] = reactive({})
144
147
  last_update_timestamp: reactive[float] = reactive( # ty: ignore[invalid-assignment]
145
148
  0.0, recompose=True
146
149
  )
@@ -151,12 +154,43 @@ class StorageListSidebar(Vertical):
151
154
  margin-right: 0;
152
155
  height: 100%;
153
156
  margin-bottom: 1;
154
- #rule-left {
157
+ .rule-left {
155
158
  width: 1;
159
+ color: $background-lighten-3;
160
+ }
161
+
162
+ .storage-credentials-container {
163
+ margin-top: 1;
164
+
165
+ & > :first-of-type {
166
+ margin-top: 0;
167
+ }
156
168
  }
157
169
 
158
170
  ScrollVerticalContainerWithNoBindings{
159
171
  height: 95%;
172
+
173
+ & > Horizontal {
174
+ height: auto;
175
+
176
+ & > .storage-credentials-name {
177
+ color: $secondary;
178
+ padding: 0 1;
179
+ }
180
+
181
+ & > Rule {
182
+ color: $background-lighten-3;
183
+ }
184
+
185
+ & > Rule.storage-credentials-rule-left {
186
+ width: 1;
187
+ color: $secondary;
188
+ }
189
+ & > Rule.storage-credentials-rule-right {
190
+ color: $secondary;
191
+ }
192
+
193
+ }
160
194
  }
161
195
 
162
196
  Horizontal {
@@ -186,20 +220,18 @@ class StorageListSidebar(Vertical):
186
220
  }
187
221
  """
188
222
 
189
- def compose(self) -> ComposeResult:
190
- with Horizontal(id="header"):
191
- if self.is_loading:
192
- yield Spinner()
193
- yield GradientWidget(
194
- " SOURCERER" if self.is_loading else "🧙SOURCERER",
195
- id="left-middle",
196
- name="header_click",
197
- )
223
+ def __init__(self, groupby_access_credentials, *args, **kwargs):
224
+ """Initialize the StorageListSidebar widget."""
225
+ super().__init__(*args, **kwargs)
226
+ self.groupby_access_credentials = groupby_access_credentials
198
227
 
199
- StorageData = namedtuple("Storage", ["access_credentials_uuid", "storage"])
228
+ def render_ungrouped_storages(self) -> ComposeResult:
200
229
  storages = [
201
230
  StorageData(access_credentials_uuid, storage)
202
- for access_credentials_uuid, storages in self.storages.items()
231
+ for (
232
+ access_credentials_uuid,
233
+ access_credentials_name,
234
+ ), storages in self.storages.items()
203
235
  for storage in storages
204
236
  ]
205
237
  storages = sorted(storages, key=lambda x: x.storage.storage)
@@ -207,20 +239,72 @@ class StorageListSidebar(Vertical):
207
239
  for letter, storages_group in groupby(
208
240
  storages, key=lambda x: x.storage.storage[0]
209
241
  ):
210
-
211
242
  yield Horizontal(
212
- Rule(id="rule-left"),
243
+ Rule(classes="rule-left"),
213
244
  Label(letter.upper(), classes="storage-letter"),
214
245
  Rule(),
246
+ classes="storage-letter-container",
215
247
  )
216
-
217
248
  for item in storages_group:
218
249
  yield StorageItem(
219
- renderable=f'{STORAGE_ICONS.get(item.storage.provider, "")} {item.storage.storage}',
250
+ renderable=f"{STORAGE_ICONS.get(item.storage.provider, '')} {item.storage.storage}",
220
251
  storage_name=item.storage.storage,
221
252
  access_credentials_uuid=item.access_credentials_uuid,
222
253
  )
223
254
 
255
+ def render_grouped_by_access_credentials_storages(self) -> ComposeResult:
256
+ """Render storages grouped by access credentials."""
257
+ with ScrollVerticalContainerWithNoBindings():
258
+ for (
259
+ access_credentials_uuid,
260
+ access_credentials_name,
261
+ ), storages in self.storages.items():
262
+ storages = sorted(
263
+ [
264
+ StorageData(access_credentials_uuid, storage)
265
+ for storage in storages
266
+ ],
267
+ key=lambda x: x.storage.storage,
268
+ )
269
+ yield Horizontal(
270
+ Rule(classes="storage-credentials-rule-left"),
271
+ Label(
272
+ access_credentials_name.upper(),
273
+ classes="storage-credentials-name",
274
+ ),
275
+ Rule(classes="storage-credentials-rule-right"),
276
+ classes="storage-credentials-container",
277
+ )
278
+ for letter, storages_group in groupby(
279
+ storages, key=lambda x: x.storage.storage[0]
280
+ ):
281
+ yield Horizontal(
282
+ Rule(classes="rule-left"),
283
+ Label(letter.upper(), classes="storage-letter"),
284
+ Rule(),
285
+ classes="storage-letter-container",
286
+ )
287
+ for item in storages_group:
288
+ yield StorageItem(
289
+ renderable=f"{STORAGE_ICONS.get(item.storage.provider, '')} {item.storage.storage}",
290
+ storage_name=item.storage.storage,
291
+ access_credentials_uuid=item.access_credentials_uuid,
292
+ )
293
+
294
+ def compose(self) -> ComposeResult:
295
+ with Horizontal(id="header"):
296
+ if self.is_loading:
297
+ yield Spinner()
298
+ yield GradientWidget(
299
+ " SOURCERER" if self.is_loading else "🧙SOURCERER",
300
+ id="left-middle",
301
+ name="header_click",
302
+ )
303
+ if self.groupby_access_credentials:
304
+ yield from self.render_grouped_by_access_credentials_storages()
305
+ else:
306
+ yield from self.render_ungrouped_storages()
307
+
224
308
  def focus(self, scroll_visible: bool = True) -> Self:
225
309
  try:
226
310
  content = self.query_one(StorageItem)