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.
- euporie/console/app.py +2 -0
- euporie/console/tabs/console.py +27 -17
- euporie/core/__init__.py +2 -2
- euporie/core/__main__.py +2 -2
- euporie/core/_settings.py +7 -2
- euporie/core/app/_commands.py +20 -12
- euporie/core/app/_settings.py +34 -4
- euporie/core/app/app.py +31 -18
- euporie/core/bars/command.py +53 -27
- euporie/core/bars/search.py +43 -2
- euporie/core/border.py +7 -2
- euporie/core/comm/base.py +2 -2
- euporie/core/comm/ipywidgets.py +3 -3
- euporie/core/commands.py +44 -24
- euporie/core/completion.py +14 -6
- euporie/core/convert/datum.py +7 -7
- euporie/core/data_structures.py +20 -1
- euporie/core/filters.py +40 -9
- euporie/core/format.py +2 -3
- euporie/core/ft/html.py +47 -40
- euporie/core/graphics.py +199 -31
- euporie/core/history.py +15 -5
- euporie/core/inspection.py +16 -9
- euporie/core/kernel/__init__.py +53 -1
- euporie/core/kernel/base.py +571 -0
- euporie/core/kernel/{client.py → jupyter.py} +173 -430
- euporie/core/kernel/{manager.py → jupyter_manager.py} +4 -3
- euporie/core/kernel/local.py +694 -0
- euporie/core/key_binding/bindings/basic.py +6 -3
- euporie/core/keys.py +26 -25
- euporie/core/layout/cache.py +31 -7
- euporie/core/layout/containers.py +88 -13
- euporie/core/layout/scroll.py +69 -170
- euporie/core/log.py +2 -5
- euporie/core/path.py +61 -13
- euporie/core/style.py +2 -1
- euporie/core/suggest.py +155 -74
- euporie/core/tabs/__init__.py +12 -4
- euporie/core/tabs/_commands.py +76 -0
- euporie/core/tabs/_settings.py +16 -0
- euporie/core/tabs/base.py +89 -9
- euporie/core/tabs/kernel.py +83 -38
- euporie/core/tabs/notebook.py +28 -76
- euporie/core/utils.py +2 -19
- euporie/core/validation.py +8 -8
- euporie/core/widgets/_settings.py +19 -2
- euporie/core/widgets/cell.py +32 -32
- euporie/core/widgets/cell_outputs.py +10 -1
- euporie/core/widgets/dialog.py +60 -76
- euporie/core/widgets/display.py +2 -2
- euporie/core/widgets/forms.py +71 -59
- euporie/core/widgets/inputs.py +7 -4
- euporie/core/widgets/layout.py +281 -93
- euporie/core/widgets/menu.py +56 -16
- euporie/core/widgets/palette.py +3 -1
- euporie/core/widgets/tree.py +86 -76
- euporie/notebook/app.py +35 -16
- euporie/notebook/tabs/display.py +2 -2
- euporie/notebook/tabs/edit.py +11 -46
- euporie/notebook/tabs/json.py +8 -4
- euporie/notebook/tabs/notebook.py +26 -8
- euporie/preview/tabs/notebook.py +17 -13
- euporie/web/__init__.py +1 -0
- euporie/web/tabs/__init__.py +14 -0
- euporie/web/tabs/web.py +30 -5
- euporie/web/widgets/__init__.py +1 -0
- euporie/web/widgets/webview.py +5 -4
- {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/METADATA +4 -2
- {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/RECORD +74 -68
- {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/entry_points.txt +1 -1
- {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/licenses/LICENSE +1 -1
- {euporie-2.8.5.data → euporie-2.8.7.data}/data/share/applications/euporie-console.desktop +0 -0
- {euporie-2.8.5.data → euporie-2.8.7.data}/data/share/applications/euporie-notebook.desktop +0 -0
- {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
|
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.
|
34
|
-
|
35
|
-
|
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
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
123
|
+
# Schedule indexing any new history items
|
124
|
+
self.process_history()
|
71
125
|
|
72
|
-
|
73
|
-
|
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(
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
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
|
-
|
87
|
-
|
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 *
|
159
|
+
+ 0.3 * pos.idx / len(texts)
|
93
160
|
# Similarity of context to document
|
94
161
|
+ 0.4 * context_similarity
|
95
162
|
)
|
96
|
-
|
97
|
-
if score > 0.
|
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
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
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):
|
euporie/core/tabs/__init__.py
CHANGED
@@ -3,7 +3,7 @@
|
|
3
3
|
from __future__ import annotations
|
4
4
|
|
5
5
|
from dataclasses import dataclass, field
|
6
|
-
from
|
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
|
-
|
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()
|
euporie/core/tabs/_settings.py
CHANGED
@@ -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
|
-
|
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
|
114
|
-
|
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=
|
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(
|
229
|
+
tab._save(UPath(path) if path else None)
|
150
230
|
except NotImplementedError:
|
151
231
|
pass
|
152
232
|
|