jjdiff 0.1.0__tar.gz
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.
- jjdiff-0.1.0/PKG-INFO +35 -0
- jjdiff-0.1.0/README.md +26 -0
- jjdiff-0.1.0/pyproject.toml +29 -0
- jjdiff-0.1.0/src/jjdiff/__init__.py +24 -0
- jjdiff-0.1.0/src/jjdiff/change.py +287 -0
- jjdiff-0.1.0/src/jjdiff/cols.py +13 -0
- jjdiff-0.1.0/src/jjdiff/diff.py +304 -0
- jjdiff-0.1.0/src/jjdiff/drawable.py +18 -0
- jjdiff-0.1.0/src/jjdiff/edit.py +1171 -0
- jjdiff-0.1.0/src/jjdiff/fill.py +30 -0
- jjdiff-0.1.0/src/jjdiff/grid.py +91 -0
- jjdiff-0.1.0/src/jjdiff/keyboard.py +81 -0
- jjdiff-0.1.0/src/jjdiff/rows.py +12 -0
- jjdiff-0.1.0/src/jjdiff/text.py +247 -0
jjdiff-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: jjdiff
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Daan van der Kallen
|
|
6
|
+
Author-email: Daan van der Kallen <mail@daanvdk.com>
|
|
7
|
+
Requires-Python: >=3.13
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# jjdiff
|
|
11
|
+
A TUI that can be used as a diff editor in the jujutsu vcs.
|
|
12
|
+
|
|
13
|
+
jjdiff will show all changes and allow you to navigate through them and
|
|
14
|
+
(partially) select them to be included.
|
|
15
|
+
|
|
16
|
+
jjdiff makes it easy to navigate the diff by having a 2 dimensional cursor that
|
|
17
|
+
can 'grow' and 'shrink'.
|
|
18
|
+
|
|
19
|
+
This cursor can operate on 3 levels:
|
|
20
|
+
- Change: select an entire change
|
|
21
|
+
- Hunk: select a group of edited lines in a file
|
|
22
|
+
- Line: select a single edited line in a file
|
|
23
|
+
|
|
24
|
+
## Keybindings
|
|
25
|
+
| Command | Key | Description |
|
|
26
|
+
| --- | --- | --- |
|
|
27
|
+
| `exit` | `escape`, `ctrl+c` or `ctrl+d` | Exit the diff editor with status code 1, causing the diff to not be applied. |
|
|
28
|
+
| `next_cursor` | `j`, `down` or `tab` | Select the next entry. |
|
|
29
|
+
| `prev_cursor` | `k`, `up` or `shift+tab` | Select the previous entry. |
|
|
30
|
+
| `shrink_cursor` | `l` or `right` | Shrink the cursor. So go from change to hunk and from hunk to line. If the cursor is on an unopened change it will open it first. |
|
|
31
|
+
| `grow_cursor` | `h` or `left` | Grow the cursor. So go from line to hunk and from hunk to change. If the cursor is on an opened change it will close it. |
|
|
32
|
+
| `select_cursor` | `space` | Mark everything selected by the cursor to be included. If everything is already marked it will exclude it instead. This will also select the next entry. |
|
|
33
|
+
| `confirm` | `enter` | Confirm the selected changes. |
|
|
34
|
+
| `undo` | `u` | Undo the last command. Commands that only affect the UI state like changing the cursor and opening/closing changes are not included in this. |
|
|
35
|
+
| `redo` | `U` | Redo the last undone command. Commands that only affect the UI state like changing the cursor and opening/closing changes are not included in this. |
|
jjdiff-0.1.0/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# jjdiff
|
|
2
|
+
A TUI that can be used as a diff editor in the jujutsu vcs.
|
|
3
|
+
|
|
4
|
+
jjdiff will show all changes and allow you to navigate through them and
|
|
5
|
+
(partially) select them to be included.
|
|
6
|
+
|
|
7
|
+
jjdiff makes it easy to navigate the diff by having a 2 dimensional cursor that
|
|
8
|
+
can 'grow' and 'shrink'.
|
|
9
|
+
|
|
10
|
+
This cursor can operate on 3 levels:
|
|
11
|
+
- Change: select an entire change
|
|
12
|
+
- Hunk: select a group of edited lines in a file
|
|
13
|
+
- Line: select a single edited line in a file
|
|
14
|
+
|
|
15
|
+
## Keybindings
|
|
16
|
+
| Command | Key | Description |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| `exit` | `escape`, `ctrl+c` or `ctrl+d` | Exit the diff editor with status code 1, causing the diff to not be applied. |
|
|
19
|
+
| `next_cursor` | `j`, `down` or `tab` | Select the next entry. |
|
|
20
|
+
| `prev_cursor` | `k`, `up` or `shift+tab` | Select the previous entry. |
|
|
21
|
+
| `shrink_cursor` | `l` or `right` | Shrink the cursor. So go from change to hunk and from hunk to line. If the cursor is on an unopened change it will open it first. |
|
|
22
|
+
| `grow_cursor` | `h` or `left` | Grow the cursor. So go from line to hunk and from hunk to change. If the cursor is on an opened change it will close it. |
|
|
23
|
+
| `select_cursor` | `space` | Mark everything selected by the cursor to be included. If everything is already marked it will exclude it instead. This will also select the next entry. |
|
|
24
|
+
| `confirm` | `enter` | Confirm the selected changes. |
|
|
25
|
+
| `undo` | `u` | Undo the last command. Commands that only affect the UI state like changing the cursor and opening/closing changes are not included in this. |
|
|
26
|
+
| `redo` | `U` | Redo the last undone command. Commands that only affect the UI state like changing the cursor and opening/closing changes are not included in this. |
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jjdiff"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Daan van der Kallen", email = "mail@daanvdk.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
jjdiff = "jjdiff:main"
|
|
14
|
+
|
|
15
|
+
[tool.pyright]
|
|
16
|
+
venvPath = "."
|
|
17
|
+
venv = ".venv"
|
|
18
|
+
reportUnusedCallResult = false
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.8.0,<0.9"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"mypy>=1.17.0",
|
|
27
|
+
"pytest>=8.4.1",
|
|
28
|
+
"ruff>=0.12.5",
|
|
29
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from .change import apply_changes, reverse_changes
|
|
6
|
+
from .diff import diff
|
|
7
|
+
from .edit import edit_changes
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
parser = argparse.ArgumentParser()
|
|
11
|
+
parser.add_argument("old", type=Path)
|
|
12
|
+
parser.add_argument("new", type=Path)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> None:
|
|
16
|
+
args = parser.parse_args()
|
|
17
|
+
old = cast(Path, args.old)
|
|
18
|
+
new = cast(Path, args.new)
|
|
19
|
+
|
|
20
|
+
new.joinpath("JJ-INSTRUCTIONS").unlink()
|
|
21
|
+
|
|
22
|
+
changes = tuple(diff(old, new))
|
|
23
|
+
apply_changes(new, reverse_changes(changes))
|
|
24
|
+
apply_changes(new, edit_changes(changes))
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import stat
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
type LineStatus = Literal["added", "deleted", "changed", "unchanged"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Line:
|
|
13
|
+
old: str | None
|
|
14
|
+
new: str | None
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def status(self) -> LineStatus:
|
|
18
|
+
if self.old is None:
|
|
19
|
+
return "added"
|
|
20
|
+
elif self.new is None:
|
|
21
|
+
return "deleted"
|
|
22
|
+
elif self.old != self.new:
|
|
23
|
+
return "changed"
|
|
24
|
+
else:
|
|
25
|
+
return "unchanged"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Rename:
|
|
30
|
+
old_path: Path
|
|
31
|
+
new_path: Path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ChangeMode:
|
|
36
|
+
path: Path
|
|
37
|
+
old_is_exec: bool
|
|
38
|
+
new_is_exec: bool
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class AddFile:
|
|
43
|
+
path: Path
|
|
44
|
+
lines: list[Line]
|
|
45
|
+
is_exec: bool
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ModifyFile:
|
|
50
|
+
path: Path
|
|
51
|
+
lines: list[Line]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class DeleteFile:
|
|
56
|
+
path: Path
|
|
57
|
+
lines: list[Line]
|
|
58
|
+
is_exec: bool
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class AddBinary:
|
|
63
|
+
path: Path
|
|
64
|
+
data: bytes
|
|
65
|
+
is_exec: bool
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ModifyBinary:
|
|
70
|
+
path: Path
|
|
71
|
+
old_data: bytes
|
|
72
|
+
new_data: bytes
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class DeleteBinary:
|
|
77
|
+
path: Path
|
|
78
|
+
data: bytes
|
|
79
|
+
is_exec: bool
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class AddSymlink:
|
|
84
|
+
path: Path
|
|
85
|
+
to: Path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class ModifySymlink:
|
|
90
|
+
path: Path
|
|
91
|
+
old_to: Path
|
|
92
|
+
new_to: Path
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class DeleteSymlink:
|
|
97
|
+
path: Path
|
|
98
|
+
to: Path
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
type FileChange = AddFile | ModifyFile | DeleteFile
|
|
102
|
+
type BinaryChange = AddBinary | ModifyBinary | DeleteBinary
|
|
103
|
+
type SymlinkChange = AddSymlink | ModifySymlink | DeleteSymlink
|
|
104
|
+
type Change = Rename | ChangeMode | FileChange | BinaryChange | SymlinkChange
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
FILE_CHANGE_TYPES = (AddFile, ModifyFile, DeleteFile)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def reverse_changes(changes: Sequence[Change]) -> Iterator[Change]:
|
|
111
|
+
renames: dict[Path, Path] = {}
|
|
112
|
+
for change in changes:
|
|
113
|
+
if isinstance(change, Rename):
|
|
114
|
+
renames[change.old_path] = change.new_path
|
|
115
|
+
|
|
116
|
+
for change in reversed(changes):
|
|
117
|
+
match change:
|
|
118
|
+
case Rename(old_path, new_path):
|
|
119
|
+
yield Rename(new_path, old_path)
|
|
120
|
+
|
|
121
|
+
case ChangeMode(path, old_is_exec, new_is_exec):
|
|
122
|
+
path = renames.get(path, path)
|
|
123
|
+
yield ChangeMode(path, new_is_exec, old_is_exec)
|
|
124
|
+
|
|
125
|
+
case AddFile(path, lines, is_exec):
|
|
126
|
+
path = renames.get(path, path)
|
|
127
|
+
yield DeleteFile(path, reverse_lines(lines), is_exec)
|
|
128
|
+
|
|
129
|
+
case ModifyFile(path, lines):
|
|
130
|
+
path = renames.get(path, path)
|
|
131
|
+
yield ModifyFile(path, reverse_lines(lines))
|
|
132
|
+
|
|
133
|
+
case DeleteFile(path, lines, is_exec):
|
|
134
|
+
path = renames.get(path, path)
|
|
135
|
+
yield AddFile(path, reverse_lines(lines), is_exec)
|
|
136
|
+
|
|
137
|
+
case AddBinary(path, data, is_exec):
|
|
138
|
+
path = renames.get(path, path)
|
|
139
|
+
yield DeleteBinary(path, data, is_exec)
|
|
140
|
+
|
|
141
|
+
case ModifyBinary(path, old_data, new_data):
|
|
142
|
+
path = renames.get(path, path)
|
|
143
|
+
yield ModifyBinary(path, new_data, old_data)
|
|
144
|
+
|
|
145
|
+
case DeleteBinary(path, data, is_exec):
|
|
146
|
+
path = renames.get(path, path)
|
|
147
|
+
yield AddBinary(path, data, is_exec)
|
|
148
|
+
|
|
149
|
+
case AddSymlink(path, to):
|
|
150
|
+
path = renames.get(path, path)
|
|
151
|
+
yield DeleteSymlink(path, to)
|
|
152
|
+
|
|
153
|
+
case ModifySymlink(path, old_to, new_to):
|
|
154
|
+
path = renames.get(path, path)
|
|
155
|
+
yield ModifySymlink(path, new_to, old_to)
|
|
156
|
+
|
|
157
|
+
case DeleteSymlink(path, to):
|
|
158
|
+
path = renames.get(path, path)
|
|
159
|
+
yield AddSymlink(path, to)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def reverse_lines(lines: list[Line]) -> list[Line]:
|
|
163
|
+
return [Line(line.new, line.old) for line in lines]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class ChangeRef:
|
|
168
|
+
change: int
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class LineRef:
|
|
173
|
+
change: int
|
|
174
|
+
line: int
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
type Ref = ChangeRef | LineRef
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def filter_changes(
|
|
181
|
+
refs: set[Ref],
|
|
182
|
+
changes: Iterable[Change],
|
|
183
|
+
) -> Iterator[Change]:
|
|
184
|
+
for change_index, change in enumerate(changes):
|
|
185
|
+
change_ref = ChangeRef(change_index)
|
|
186
|
+
|
|
187
|
+
# For non file changes we just include the whole change or not
|
|
188
|
+
if not isinstance(change, FILE_CHANGE_TYPES):
|
|
189
|
+
if change_ref in refs:
|
|
190
|
+
yield change
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
# Now that we know we have a file change, we first filter the lines
|
|
194
|
+
lines: list[Line] = []
|
|
195
|
+
line_changes = False
|
|
196
|
+
|
|
197
|
+
for line_index, line in enumerate(change.lines):
|
|
198
|
+
line_ref = LineRef(change_index, line_index)
|
|
199
|
+
if line_ref in refs:
|
|
200
|
+
lines.append(line)
|
|
201
|
+
line_changes = True
|
|
202
|
+
elif line.old is not None:
|
|
203
|
+
lines.append(Line(line.old, line.old))
|
|
204
|
+
|
|
205
|
+
# Now we can check what the filtered change looks like
|
|
206
|
+
match change:
|
|
207
|
+
case AddFile(path, _, is_exec):
|
|
208
|
+
if change_ref in refs:
|
|
209
|
+
yield AddFile(path, lines, is_exec)
|
|
210
|
+
|
|
211
|
+
case ModifyFile(path):
|
|
212
|
+
if line_changes:
|
|
213
|
+
yield ModifyFile(path, lines)
|
|
214
|
+
|
|
215
|
+
case DeleteFile(path, _, is_exec):
|
|
216
|
+
if change_ref in refs:
|
|
217
|
+
yield DeleteFile(path, lines, is_exec)
|
|
218
|
+
elif line_changes:
|
|
219
|
+
yield ModifyFile(path, lines)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def apply_changes(root: Path, changes: Iterable[Change]) -> None:
|
|
223
|
+
for change in changes:
|
|
224
|
+
apply_change(root, change)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def apply_change(root: Path, change: Change) -> None:
|
|
228
|
+
renames: dict[Path, Path] = {}
|
|
229
|
+
|
|
230
|
+
match change:
|
|
231
|
+
case Rename(old_path, new_path):
|
|
232
|
+
full_old_path = root / old_path
|
|
233
|
+
full_new_path = root / new_path
|
|
234
|
+
full_old_path.rename(full_new_path)
|
|
235
|
+
|
|
236
|
+
case ChangeMode(path, _, is_exec):
|
|
237
|
+
path = renames.get(path, path)
|
|
238
|
+
full_path = root / path
|
|
239
|
+
|
|
240
|
+
mode = full_path.stat().st_mode
|
|
241
|
+
if is_exec:
|
|
242
|
+
mode |= stat.S_IXUSR
|
|
243
|
+
else:
|
|
244
|
+
mode &= ~stat.S_IXUSR
|
|
245
|
+
full_path.chmod(mode)
|
|
246
|
+
|
|
247
|
+
case (
|
|
248
|
+
AddFile(path)
|
|
249
|
+
| ModifyFile(path)
|
|
250
|
+
| AddBinary(path)
|
|
251
|
+
| ModifyBinary(path)
|
|
252
|
+
| AddSymlink(path)
|
|
253
|
+
| ModifySymlink(path)
|
|
254
|
+
):
|
|
255
|
+
path = renames.get(path, path)
|
|
256
|
+
full_path = root / path
|
|
257
|
+
|
|
258
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
|
|
260
|
+
match change:
|
|
261
|
+
case AddFile(_, lines) | ModifyFile(_, lines):
|
|
262
|
+
full_path.write_text(lines_to_text(lines))
|
|
263
|
+
case AddBinary(_, data) | ModifyBinary(_, _, data):
|
|
264
|
+
full_path.write_bytes(data)
|
|
265
|
+
case AddSymlink(_, to) | ModifySymlink(_, _, to):
|
|
266
|
+
full_path.symlink_to(to)
|
|
267
|
+
|
|
268
|
+
if isinstance(change, (AddFile, AddBinary)) and change.is_exec:
|
|
269
|
+
mode = full_path.stat().st_mode
|
|
270
|
+
mode |= stat.S_IXUSR
|
|
271
|
+
full_path.chmod(mode)
|
|
272
|
+
|
|
273
|
+
case DeleteFile(path) | DeleteBinary(path) | DeleteSymlink(path):
|
|
274
|
+
path = renames.get(path, path)
|
|
275
|
+
full_path = root / path
|
|
276
|
+
|
|
277
|
+
full_path.unlink()
|
|
278
|
+
|
|
279
|
+
full_path = full_path.parent
|
|
280
|
+
while full_path != root and not any(full_path.iterdir()):
|
|
281
|
+
full_path.relative_to(root)
|
|
282
|
+
full_path.rmdir()
|
|
283
|
+
full_path = full_path.parent
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def lines_to_text(lines: list[Line]) -> str:
|
|
287
|
+
return "\n".join(line.new for line in lines if line.new is not None)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
|
|
3
|
+
from .drawable import Drawable
|
|
4
|
+
from .grid import Grid
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Cols(Grid):
|
|
8
|
+
def __init__(self, drawables: Iterable[Drawable]):
|
|
9
|
+
drawables = tuple(drawables)
|
|
10
|
+
super().__init__(
|
|
11
|
+
tuple(1 for _ in drawables),
|
|
12
|
+
[drawables],
|
|
13
|
+
)
|