euporie 2.8.5__py3-none-any.whl → 2.8.7__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 (74) hide show
  1. euporie/console/app.py +2 -0
  2. euporie/console/tabs/console.py +27 -17
  3. euporie/core/__init__.py +2 -2
  4. euporie/core/__main__.py +2 -2
  5. euporie/core/_settings.py +7 -2
  6. euporie/core/app/_commands.py +20 -12
  7. euporie/core/app/_settings.py +34 -4
  8. euporie/core/app/app.py +31 -18
  9. euporie/core/bars/command.py +53 -27
  10. euporie/core/bars/search.py +43 -2
  11. euporie/core/border.py +7 -2
  12. euporie/core/comm/base.py +2 -2
  13. euporie/core/comm/ipywidgets.py +3 -3
  14. euporie/core/commands.py +44 -24
  15. euporie/core/completion.py +14 -6
  16. euporie/core/convert/datum.py +7 -7
  17. euporie/core/data_structures.py +20 -1
  18. euporie/core/filters.py +40 -9
  19. euporie/core/format.py +2 -3
  20. euporie/core/ft/html.py +47 -40
  21. euporie/core/graphics.py +199 -31
  22. euporie/core/history.py +15 -5
  23. euporie/core/inspection.py +16 -9
  24. euporie/core/kernel/__init__.py +53 -1
  25. euporie/core/kernel/base.py +571 -0
  26. euporie/core/kernel/{client.py → jupyter.py} +173 -430
  27. euporie/core/kernel/{manager.py → jupyter_manager.py} +4 -3
  28. euporie/core/kernel/local.py +694 -0
  29. euporie/core/key_binding/bindings/basic.py +6 -3
  30. euporie/core/keys.py +26 -25
  31. euporie/core/layout/cache.py +31 -7
  32. euporie/core/layout/containers.py +88 -13
  33. euporie/core/layout/scroll.py +69 -170
  34. euporie/core/log.py +2 -5
  35. euporie/core/path.py +61 -13
  36. euporie/core/style.py +2 -1
  37. euporie/core/suggest.py +155 -74
  38. euporie/core/tabs/__init__.py +12 -4
  39. euporie/core/tabs/_commands.py +76 -0
  40. euporie/core/tabs/_settings.py +16 -0
  41. euporie/core/tabs/base.py +89 -9
  42. euporie/core/tabs/kernel.py +83 -38
  43. euporie/core/tabs/notebook.py +28 -76
  44. euporie/core/utils.py +2 -19
  45. euporie/core/validation.py +8 -8
  46. euporie/core/widgets/_settings.py +19 -2
  47. euporie/core/widgets/cell.py +32 -32
  48. euporie/core/widgets/cell_outputs.py +10 -1
  49. euporie/core/widgets/dialog.py +60 -76
  50. euporie/core/widgets/display.py +2 -2
  51. euporie/core/widgets/forms.py +71 -59
  52. euporie/core/widgets/inputs.py +7 -4
  53. euporie/core/widgets/layout.py +281 -93
  54. euporie/core/widgets/menu.py +56 -16
  55. euporie/core/widgets/palette.py +3 -1
  56. euporie/core/widgets/tree.py +86 -76
  57. euporie/notebook/app.py +35 -16
  58. euporie/notebook/tabs/display.py +2 -2
  59. euporie/notebook/tabs/edit.py +11 -46
  60. euporie/notebook/tabs/json.py +8 -4
  61. euporie/notebook/tabs/notebook.py +26 -8
  62. euporie/preview/tabs/notebook.py +17 -13
  63. euporie/web/__init__.py +1 -0
  64. euporie/web/tabs/__init__.py +14 -0
  65. euporie/web/tabs/web.py +30 -5
  66. euporie/web/widgets/__init__.py +1 -0
  67. euporie/web/widgets/webview.py +5 -4
  68. {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/METADATA +4 -2
  69. {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/RECORD +74 -68
  70. {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/entry_points.txt +1 -1
  71. {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/licenses/LICENSE +1 -1
  72. {euporie-2.8.5.data → euporie-2.8.7.data}/data/share/applications/euporie-console.desktop +0 -0
  73. {euporie-2.8.5.data → euporie-2.8.7.data}/data/share/applications/euporie-notebook.desktop +0 -0
  74. {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/WHEEL +0 -0
euporie/core/suggest.py CHANGED
@@ -2,13 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import logging
6
- from collections import defaultdict
7
+ from collections import defaultdict, deque
7
8
  from difflib import SequenceMatcher
8
- from functools import lru_cache
9
- from typing import TYPE_CHECKING
9
+ from functools import lru_cache, partial
10
+ from typing import TYPE_CHECKING, NamedTuple
10
11
 
11
12
  from prompt_toolkit.auto_suggest import AutoSuggest, ConditionalAutoSuggest, Suggestion
13
+ from prompt_toolkit.cache import SimpleCache
12
14
  from prompt_toolkit.filters import to_filter
13
15
 
14
16
  if TYPE_CHECKING:
@@ -21,39 +23,86 @@ if TYPE_CHECKING:
21
23
  log = logging.getLogger(__name__)
22
24
 
23
25
 
24
- class HistoryAutoSuggest(AutoSuggest):
26
+ class HistoryPosition(NamedTuple):
27
+ """Store position information for a history match."""
28
+
29
+ idx: int # Index in history
30
+ context_start: int # Position where context starts
31
+ context_end: int # Position where context ends
32
+
33
+
34
+ class SmartHistoryAutoSuggest(AutoSuggest):
25
35
  """Suggest line completions from a :class:`History` object."""
26
36
 
37
+ _context_lines = 10
38
+ _max_line_len = 200
39
+ _max_item_lines = 1000
40
+
27
41
  def __init__(self, history: History) -> None:
28
42
  """Set the kernel instance in initialization."""
29
43
  self.history = history
30
- self.calculate_similarity = lru_cache(maxsize=1024)(self._calculate_similarity)
31
44
 
32
45
  self.n_texts = 0
33
- self.n_lines = 0
34
- self.prefix_dict: dict[str, dict[str, list[dict[str, int]]]] = defaultdict(
35
- lambda: defaultdict(list)
46
+ self._processing_task: asyncio.Task | None = None
47
+ # Index storage
48
+ self.prefix_tree: dict[str, list[int]] = defaultdict(list)
49
+ self.suffix_data: list[tuple[str, HistoryPosition]] = []
50
+ # Caches
51
+ self.calculate_similarity = lru_cache(maxsize=128)(self._calculate_similarity)
52
+ self.match_cache: SimpleCache[tuple[str, int, int], Suggestion | None] = (
53
+ SimpleCache(maxsize=128)
36
54
  )
37
55
 
38
56
  def process_history(self) -> None:
57
+ """Schedule history processing if not already running."""
58
+ from euporie.notebook.current import get_app
59
+
60
+ if self._processing_task is not None and not self._processing_task.done():
61
+ return
62
+
63
+ # Schedule the actual processing to run when idle
64
+ self._processing_task = get_app().create_background_task(
65
+ self._process_history_async()
66
+ )
67
+
68
+ async def _process_history_async(self) -> None:
39
69
  """Process the entire history and store in prefix_dict."""
40
70
  texts = self.history._loaded_strings
41
- if texts := texts[: len(texts) - self.n_texts]:
42
- n_lines = self.n_lines
43
- prefix_dict = self.prefix_dict
44
- for i, text in enumerate(reversed(texts)):
45
- for line in text.strip().splitlines():
46
- n_lines += 1
47
- line = line.strip()
48
- for j in range(1, len(line)):
49
- prefix, suffix = line[:j], line[j:]
50
- prefix_dict[prefix][suffix].append(
51
- {"index": -1 - i, "line": n_lines}
52
- )
53
- # for k in range(1, len(prefix)):
54
- # prefix_dict[prefix[-k:]] = prefix_dict[prefix]
55
- self.n_lines = n_lines
56
- self.n_texts += len(texts)
71
+ # Only process new history items
72
+ if not (texts := texts[: -self.n_texts or None]):
73
+ return
74
+
75
+ prefix_tree = self.prefix_tree
76
+ suffix_data = self.suffix_data
77
+ context_lines = self._context_lines
78
+ max_item_lines = self._max_item_lines
79
+ max_line_len = self._max_line_len
80
+
81
+ for i, text in enumerate(texts):
82
+ # Add tiny sleep to prevent blocking
83
+ if i > 1:
84
+ await asyncio.sleep(0.001)
85
+
86
+ lines = text.splitlines(keepends=True)
87
+ # Calculate positions of newlines
88
+ line_pos = [0]
89
+ for line in lines:
90
+ line_pos.append(line_pos[-1] + len(line))
91
+ # Index each line
92
+ for j, line in enumerate(lines[:max_item_lines]):
93
+ context_start = line_pos[max(0, j - context_lines)]
94
+ context_end = line_pos[min(j + context_lines, len(lines))]
95
+ hist_pos = HistoryPosition(
96
+ idx=i, context_start=context_start, context_end=context_end
97
+ )
98
+ line = line.strip()
99
+ # Create prefix/suffix combinations
100
+ for k in range(min(len(line), max_line_len)):
101
+ prefix, suffix = line[:k], line[k:]
102
+ prefix_tree[prefix].append(len(suffix_data))
103
+ suffix_data.append((suffix, hist_pos))
104
+
105
+ self.n_texts += len(texts)
57
106
 
58
107
  def _calculate_similarity(self, text_1: str, text_2: str) -> float:
59
108
  """Calculate and cache the similarity between two texts."""
@@ -61,79 +110,111 @@ class HistoryAutoSuggest(AutoSuggest):
61
110
 
62
111
  def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None:
63
112
  """Get a line completion suggestion."""
64
- self.process_history()
113
+ # Only return suggestions if cursor is at end of line
114
+ if not document.is_cursor_at_the_end_of_line:
115
+ return None
65
116
 
66
117
  line = document.current_line.lstrip()
67
- if not line:
118
+
119
+ # Skip empty and very long lines
120
+ if not line or len(line) > self._max_line_len:
68
121
  return None
69
122
 
70
- suffixes = self.prefix_dict[line]
123
+ # Schedule indexing any new history items
124
+ self.process_history()
71
125
 
72
- texts = self.history._loaded_strings
73
- n_lines = self.n_lines
126
+ # Find matches
127
+ key = line, hash(document.text), len(self.suffix_data)
128
+ return self.match_cache.get(key, partial(self._find_match, line, document.text))
74
129
 
130
+ def _find_match(self, line: str, document_text: str) -> Suggestion | None:
131
+ if not (suffix_indices := self.prefix_tree[line]):
132
+ return None
133
+
134
+ texts = self.history._loaded_strings
75
135
  best_score = 0.0
76
- best_suffix = ""
136
+ best_suffix = None
77
137
 
78
138
  # Rank candidates
79
- max_count = max([1, *(len(x) for x in suffixes.values())])
80
- for suffix, instances in suffixes.items():
81
- count = len(instances)
82
- for instance in instances:
83
- text = texts[instance["index"]]
84
- context_similarity = self.calculate_similarity(document.text, text)
139
+ max_count = max(1, len(suffix_indices))
140
+ suffix_groups: dict[str, list[HistoryPosition]] = defaultdict(list)
141
+
142
+ # Group suffixes and their positions
143
+ for idx in suffix_indices:
144
+ suffix, pos = self.suffix_data[idx]
145
+ suffix_groups[suffix].append(pos)
146
+
147
+ # Evaluate each unique suffix
148
+ for suffix, positions in suffix_groups.items():
149
+ count = len(positions)
150
+ for pos in positions[:10]:
151
+ # Get the text using the stored positions
152
+ text = texts[pos.idx]
153
+ context = text[pos.context_start : pos.context_end]
154
+ context_similarity = self.calculate_similarity(document_text, context)
85
155
  score = (
86
- 0
87
- # Similarity of prefix to line
88
- # 0.333 * len(line) / len(match.group("prefix"))
89
- # NUmber of instances in history
90
- + 0.3 * count / max_count
156
+ # Number of instances in history
157
+ 0.3 * count / max_count
91
158
  # Recentness
92
- + 0.3 * instance["line"] / n_lines
159
+ + 0.3 * pos.idx / len(texts)
93
160
  # Similarity of context to document
94
161
  + 0.4 * context_similarity
95
162
  )
96
- # log.debug("%s %r", score, suffix)
97
- if score > 0.95:
163
+
164
+ if score > 0.9:
98
165
  return Suggestion(suffix)
99
166
  if score > best_score:
100
167
  best_score = score
101
168
  best_suffix = suffix
169
+
102
170
  if best_suffix:
103
171
  return Suggestion(best_suffix)
104
172
  return None
105
173
 
106
174
 
107
- # class KernelAutoSuggest(AutoSuggest):
108
- # """Suggest line completions from kernel history."""
109
-
110
- # def __init__(self, kernel: Kernel) -> None:
111
- # """Set the kernel instance in initialization."""
112
- # self.kernel = kernel
113
-
114
- # def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None:
115
- # """Doe nothing."""
116
- # return None
117
-
118
- # async def get_suggestion_async(
119
- # self, buff: Buffer, document: Document
120
- # ) -> Suggestion | None:
121
- # """Return suggestions based on matching kernel history."""
122
- # line = document.current_line.strip()
123
- # if line:
124
- # suggestions = await self.kernel.history_(f"*{line}*")
125
- # log.debug("Suggestor got suggestions %s", suggestions)
126
- # if suggestions:
127
- # _, _, text = suggestions[0]
128
- # # Find matching line
129
- # for hist_line in text.split("\n"):
130
- # hist_line = hist_line.strip()
131
- # if hist_line.startswith(line):
132
- # # Return from the match to end from the history line
133
- # suggestion = hist_line[len(line) :]
134
- # log.debug("Suggesting %s", suggestion)
135
- # return Suggestion(suggestion)
136
- # return None
175
+ class SimpleHistoryAutoSuggest(AutoSuggest):
176
+ """Suggest line completions from a :class:`History` object."""
177
+
178
+ def __init__(self, history: History, cache_size: int = 100_000) -> None:
179
+ """Set the kernel instance in initialization."""
180
+ self.history = history
181
+
182
+ self.cache_size = cache_size
183
+ self.cache_keys: deque[str] = deque()
184
+ self.cache: dict[str, Suggestion] = {}
185
+
186
+ def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None:
187
+ """Get a line completion suggestion."""
188
+ result: Suggestion | None = None
189
+ line = document.current_line.lstrip()
190
+ if line:
191
+ if line in self.cache:
192
+ result = self.cache[line]
193
+ else:
194
+ result = self.lookup_suggestion(line)
195
+ if result:
196
+ if len(self.cache) > self.cache_size:
197
+ key_to_remove = self.cache_keys.popleft()
198
+ if key_to_remove in self.cache:
199
+ del self.cache[key_to_remove]
200
+
201
+ self.cache_keys.append(line)
202
+ self.cache[line] = result
203
+ return result
204
+
205
+ def lookup_suggestion(self, line: str) -> Suggestion | None:
206
+ """Find the most recent matching line in the history."""
207
+ # Loop history, most recent item first
208
+ for text in self.history._loaded_strings:
209
+ if line in text:
210
+ # Loop over lines of item in reverse order
211
+ for hist_line in text.splitlines()[::-1]:
212
+ hist_line = hist_line.strip()
213
+ if hist_line.startswith(line):
214
+ # Return from the match to end from the history line
215
+ suggestion = hist_line[len(line) :]
216
+ return Suggestion(suggestion)
217
+ return None
137
218
 
138
219
 
139
220
  class ConditionalAutoSuggestAsync(ConditionalAutoSuggest):
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass, field
6
- from importlib import import_module
6
+ from pkgutil import resolve_name
7
7
  from typing import TYPE_CHECKING
8
8
 
9
9
  if TYPE_CHECKING:
@@ -23,13 +23,21 @@ class TabRegistryEntry:
23
23
  @property
24
24
  def tab_class(self) -> type[Tab]:
25
25
  """Import and return the tab class."""
26
- module_path, _, attribute = self.path.partition(":")
27
- module = import_module(module_path)
28
- return getattr(module, attribute)
26
+ return resolve_name(self.path)
29
27
 
30
28
  def __lt__(self, other: TabRegistryEntry) -> bool:
31
29
  """Sort by weight."""
32
30
  return self.weight < other.weight
33
31
 
32
+ def __hash__(self) -> int:
33
+ """Make the class hashable based on its path."""
34
+ return hash(self.path)
35
+
36
+ def __eq__(self, other: object) -> bool:
37
+ """Compare TabRegistryEntry objects based on their path."""
38
+ if not isinstance(other, TabRegistryEntry):
39
+ return NotImplemented
40
+ return self.path == other.path
41
+
34
42
 
35
43
  _TAB_REGISTRY: list[TabRegistryEntry] = []
@@ -0,0 +1,76 @@
1
+ """Contains commands for tabs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from euporie.core.commands import add_cmd
8
+ from euporie.core.filters import tab_can_save, tab_has_focus
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ @add_cmd(filter=tab_can_save, aliases=["w"])
14
+ def _save_file(path: str = "") -> None:
15
+ """Save the current file."""
16
+ from euporie.core.app.current import get_app
17
+
18
+ if (tab := get_app().tab) is not None:
19
+ from upath import UPath
20
+
21
+ try:
22
+ tab._save(UPath(path) if path else None)
23
+ except NotImplementedError:
24
+ pass
25
+
26
+
27
+ @add_cmd(aliases=["wq", "x"])
28
+ def _save_and_quit(path: str = "") -> None:
29
+ """Save the current tab then quits euporie."""
30
+ from euporie.core.app.current import get_app
31
+
32
+ app = get_app()
33
+ if (tab := app.tab) is not None:
34
+ from upath import UPath
35
+
36
+ try:
37
+ tab.save(UPath(path) if path else None)
38
+ except NotImplementedError:
39
+ pass
40
+
41
+ app.exit()
42
+
43
+
44
+ @add_cmd(
45
+ menu_title="Save As…",
46
+ filter=tab_can_save,
47
+ )
48
+ def _save_as(path: str = "") -> None:
49
+ """Save the current file at a new location."""
50
+ if path:
51
+ _save_file(path)
52
+ else:
53
+ from euporie.core.app.current import get_app
54
+
55
+ app = get_app()
56
+ if dialog := app.dialogs.get("save-as"):
57
+ dialog.show(tab=app.tab)
58
+
59
+
60
+ @add_cmd(filter=tab_has_focus, title="Refresh the current tab")
61
+ def _refresh_tab() -> None:
62
+ """Reload the tab contents and reset the tab."""
63
+ from euporie.core.app.current import get_app
64
+
65
+ if (tab := get_app().tab) is not None:
66
+ tab.reset()
67
+
68
+
69
+ # Depreciated v2.5.0
70
+ @add_cmd(filter=tab_has_focus, title="Reset the current tab")
71
+ def _reset_tab() -> None:
72
+ log.warning(
73
+ "The `reset-tab` command was been renamed to `refresh-tab` in v2.5.0,"
74
+ " and will be removed in a future version"
75
+ )
76
+ _refresh_tab()
@@ -4,6 +4,22 @@ from prompt_toolkit.filters import buffer_has_focus
4
4
 
5
5
  from euporie.core.config import add_setting
6
6
 
7
+ add_setting(
8
+ name="backup_on_save",
9
+ group="euporie.core.tabs.base",
10
+ flags=["--backup-on-save"],
11
+ type_=bool,
12
+ help_="Create backups before saving files",
13
+ default=False,
14
+ description="""
15
+ Determines whether a backup file should be created before saving a file.
16
+
17
+ If set to ``True``, the original file will be copied to a new file with the
18
+ same name but prefixed with ``.`` and suffixed with ``.bak`` before writing the
19
+ updated contents.
20
+ """,
21
+ )
22
+
7
23
  add_setting(
8
24
  name="kernel_name",
9
25
  group="euporie.core.tabs.kernel",
euporie/core/tabs/base.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import logging
6
7
  from abc import ABCMeta
7
8
  from typing import TYPE_CHECKING, ClassVar
@@ -13,20 +14,21 @@ from upath import UPath
13
14
 
14
15
  from euporie.core.app.current import get_app
15
16
  from euporie.core.commands import add_cmd
16
- from euporie.core.filters import tab_has_focus
17
+ from euporie.core.filters import tab_can_save, tab_has_focus
17
18
  from euporie.core.key_binding.registry import (
18
19
  register_bindings,
19
20
  )
20
21
  from euporie.core.layout.containers import Window
21
- from euporie.core.path import parse_path
22
- from euporie.core.utils import run_in_thread_with_context
22
+ from euporie.core.path import UntitledPath, parse_path
23
23
 
24
24
  if TYPE_CHECKING:
25
+ from collections.abc import Sequence
25
26
  from pathlib import Path
26
27
  from typing import Any, Callable
27
28
 
28
29
  from prompt_toolkit.key_binding.key_processor import KeyPressEvent
29
30
  from prompt_toolkit.layout.containers import AnyContainer
31
+ from prompt_toolkit.layout.controls import BufferControl
30
32
 
31
33
  from euporie.core.app.app import BaseApp
32
34
  from euporie.core.bars.status import StatusBarFields
@@ -107,11 +109,89 @@ class Tab(metaclass=ABCMeta):
107
109
  cb()
108
110
  self.after_save.fire()
109
111
 
110
- run_in_thread_with_context(self.save, path, _wrapped_cb)
112
+ self.app.create_background_task(asyncio.to_thread(self.save, path, _wrapped_cb))
111
113
 
112
114
  def save(self, path: Path | None = None, cb: Callable | None = None) -> None:
113
- """Save the current tab."""
114
- raise NotImplementedError
115
+ """Save the current file."""
116
+ if path is not None:
117
+ self.path = path
118
+
119
+ if (self.path is None or isinstance(self.path, UntitledPath)) and (
120
+ dialog := self.app.dialogs.get("save-as")
121
+ ):
122
+ dialog.show(tab=self, cb=cb)
123
+ return
124
+
125
+ path = self.path
126
+ try:
127
+ # Ensure parent path exists
128
+ parent = path.parent
129
+ parent.mkdir(exist_ok=True, parents=True)
130
+
131
+ # Create backup if original file exists
132
+ backup_path: Path | None = None
133
+ if path.exists():
134
+ name = f"{path.name}.bak"
135
+ if not name.startswith("."):
136
+ name = f".{name}"
137
+ backup_path = parent / name
138
+ if self.app.config.backup_on_save:
139
+ try:
140
+ import shutil
141
+
142
+ shutil.copy2(path, backup_path)
143
+ except Exception as e:
144
+ log.error("Failed to create backup: %s", e)
145
+ raise
146
+
147
+ # Write new content directly to original file
148
+ try:
149
+ self.write_file(path)
150
+ except Exception as e:
151
+ log.error("Failed to write file: %s", e)
152
+ # Restore from backup if it exists
153
+ if backup_path is not None:
154
+ log.info("Restoring backup")
155
+ backup_path.replace(path)
156
+ raise
157
+
158
+ self.dirty = False
159
+ self.saving = False
160
+ self.app.invalidate()
161
+ log.debug("File saved successfully")
162
+
163
+ # Run the callback
164
+ if callable(cb):
165
+ cb()
166
+
167
+ except Exception:
168
+ log.exception("An error occurred while saving the file")
169
+ if dialog := self.app.dialogs.get("save-as"):
170
+ dialog.show(tab=self, cb=cb)
171
+
172
+ def write_file(self, path: Path) -> None:
173
+ """Write the tab's data to a path.
174
+
175
+ Not implement in the base tab.
176
+
177
+ Args:
178
+ path: An path at which to save the file
179
+
180
+ """
181
+ raise NotImplementedError(
182
+ f"File saving not implement for `{self.__class__.__name__}` tab"
183
+ )
184
+
185
+ def __pt_searchables__(self) -> Sequence[BufferControl]:
186
+ """Return a list of searchable buffer controls for this tab.
187
+
188
+ Returns:
189
+ A list of searchable buffer controls
190
+
191
+ Raises:
192
+ NotImplementedError: If the tab does not provide searchable buffers
193
+ """
194
+ raise NotImplementedError()
115
195
 
116
196
  def __pt_status__(self) -> StatusBarFields | None:
117
197
  """Return a list of statusbar field values shown then this tab is active."""
@@ -141,12 +221,12 @@ class Tab(metaclass=ABCMeta):
141
221
  Tab._refresh_tab()
142
222
 
143
223
  @staticmethod
144
- @add_cmd(filter=tab_has_focus, aliases=["w"])
145
- def _save_file(event: KeyPressEvent) -> None:
224
+ @add_cmd(filter=tab_can_save, aliases=["w"])
225
+ def _save_file(event: KeyPressEvent, path: str = "") -> None:
146
226
  """Save the current file."""
147
227
  if (tab := get_app().tab) is not None:
148
228
  try:
149
- tab._save(UPath(event._arg) if event._arg else None)
229
+ tab._save(UPath(path) if path else None)
150
230
  except NotImplementedError:
151
231
  pass
152
232