Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,16 @@ apart.
enrichment on top. Copying is `IViewerWindow.SetClipboard` rather than a `ViewerActions` member,
because a clipboard belongs to a toolkit the way a window does, and it is answered before the
owner link: the text is already in this process.
- An entry opens at its first change, not line 1: every path that changes what is being read goes
through `ViewerSession.Open`, so none resets to row 0 on its own. The minimal view ("Changes
only", `SessionState.Minimal`) is a second `DiffView` built with each entry - changes plus
`DiffView.Context` rows either side, longer unchanged runs folded into one `RowKind.Folded` row.
Scrolling, the scrollbar and navigation count rows of the view on screen; a selection stays in
rows of the entry (`ViewerSession.Drag` unfolds the head's rows), so it survives switching views
and a fold inside it copies what it stands for. Navigation is defined over where it lands a
change - `Context` rows under the top - which is what makes previous undo next. The fold kind and
the `m` key are additive ABI values with no `DEVIEW_VERSION` bump, as `DEVIEW_QUEUE_HEADER` was:
a stale library draws a fold as a plain row.
- Queue tooltips are composed once in `QueueProjection`, not per head, and are **null when they
would only repeat the row**. Labels are already the shortest distinguishing form, so the tip is
what the label left off — path, test, frameworks, failure text. `QueueTooltipTests` snapshots the
Expand Down
12 changes: 11 additions & 1 deletion docs/mdsource/viewer.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ Nothing is written to disk for inline review. The patch travels over stdin, or o
| Key | Action |
| --- | --- |
| `Up` `Down` `PgUp` `PgDn` `Home` `End` | Scroll |
| `n` `p` | Next and previous change |
| `n` `p` | Next and previous change (also the **Next change** and **Prev change** buttons) |
| `m` | Show only the changes, or every line (also the **Changes only** button) |
| `Tab` `Shift+Tab` | Next and previous pending item |
| `a` | Accept |
| `Shift+A` | Accept all |
Expand All @@ -81,6 +82,15 @@ Nothing is written to disk for inline review. The patch travels over stdin, or o
| `q` `Esc` | Close |


## Moving between changes

A comparison opens scrolled to its first change, with three lines of context above it, rather than at line 1. **Next change** and **Prev change** move from one change to the next, putting each in the same place under the top of the pane, and each is disabled when no change is left in its direction.

**Changes only** switches to a minimal view: each change with the three lines either side of it, and every longer run of unchanged lines folded into one row saying how many lines it stands for. The button then reads **All lines**, which switches back. Switching keeps the line being read where it is on screen, and the choice holds while moving through the queue. Images are never folded, because their rows are their properties and each is worth reading.

A fold is only a view. A selection that spans one copies the lines it stands for, since those are what lies between the selection's two ends, and the status line names the stretch of the file on screen rather than a count of rows: sixteen rows in the minimal view can read `lines 1-30 of 40`.


## Selecting and copying

Drag across either pane to select text, and `Ctrl+C` to copy it. `Ctrl+A` selects one whole pane: the one something is already selected in, or the received side when nothing is. On macOS the Edit menu carries both, so `Cmd+C` and `Cmd+A` work there too.
Expand Down
12 changes: 11 additions & 1 deletion docs/viewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ Nothing is written to disk for inline review. The patch travels over stdin, or o
| Key | Action |
| --- | --- |
| `Up` `Down` `PgUp` `PgDn` `Home` `End` | Scroll |
| `n` `p` | Next and previous change |
| `n` `p` | Next and previous change (also the **Next change** and **Prev change** buttons) |
| `m` | Show only the changes, or every line (also the **Changes only** button) |
| `Tab` `Shift+Tab` | Next and previous pending item |
| `a` | Accept |
| `Shift+A` | Accept all |
Expand All @@ -88,6 +89,15 @@ Nothing is written to disk for inline review. The patch travels over stdin, or o
| `q` `Esc` | Close |


## Moving between changes

A comparison opens scrolled to its first change, with three lines of context above it, rather than at line 1. **Next change** and **Prev change** move from one change to the next, putting each in the same place under the top of the pane, and each is disabled when no change is left in its direction.

**Changes only** switches to a minimal view: each change with the three lines either side of it, and every longer run of unchanged lines folded into one row saying how many lines it stands for. The button then reads **All lines**, which switches back. Switching keeps the line being read where it is on screen, and the choice holds while moving through the queue. Images are never folded, because their rows are their properties and each is worth reading.

A fold is only a view. A selection that spans one copies the lines it stands for, since those are what lies between the selection's two ends, and the status line names the stretch of the file on screen rather than a count of rows: sixteen rows in the minimal view can read `lines 1-30 of 40`.


## Selecting and copying

Drag across either pane to select text, and `Ctrl+C` to copy it. `Ctrl+A` selects one whole pane: the one something is already selected in, or the received side when nothing is. On macOS the Edit menu carries both, so `Cmd+C` and `Cmd+A` work there too.
Expand Down
14 changes: 11 additions & 3 deletions native/include/deview.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ enum DeviewRowKind {
DEVIEW_ROW_ADDED = 1,
DEVIEW_ROW_REMOVED = 2,
DEVIEW_ROW_MODIFIED = 3,
DEVIEW_ROW_FILLER = 4
DEVIEW_ROW_FILLER = 4,
/*
* A run of unchanged lines the minimal view left out, as one row: its text says how many, and
* it has no line number. Drawn dimmed on a band of its own. A library built before this kind
* existed draws it as a plain unchanged row, which still reads.
*/
DEVIEW_ROW_FOLDED = 5
};

enum DeviewButtonFlags {
Expand All @@ -47,7 +53,7 @@ enum DeviewQueueFlags {

typedef struct DeviewRow {
int32_t kind;
/* -1 when the row is filler and has no line number. */
/* -1 when the row is filler or folded and has no line number. */
int32_t lineNumber;
int32_t textOffset;
int32_t textLength;
Expand Down Expand Up @@ -184,7 +190,9 @@ enum DeviewKey {
/* Ctrl+C, and Cmd+C on macOS. */
DEVIEW_KEY_COPY = 16,
/* Ctrl+A, which is why plain A must be reported as accept only when no modifier is held. */
DEVIEW_KEY_SELECT_ALL = 17
DEVIEW_KEY_SELECT_ALL = 17,
/* M: every line, or only the changes and the lines around them. */
DEVIEW_KEY_TOGGLE_MINIMAL = 18
};

typedef struct DeviewInput {
Expand Down
8 changes: 8 additions & 0 deletions native/src/deview.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,9 @@ ImU32 RowColour(int kind)
return IM_COL32(233, 129, 129, 255);
case DEVIEW_ROW_MODIFIED:
return IM_COL32(231, 197, 113, 255);
/* Dimmed like the gutter, since what it says is about the file rather than from it. */
case DEVIEW_ROW_FOLDED:
return IM_COL32(130, 130, 130, 255);
default:
return IM_COL32(212, 212, 212, 255);
}
Expand All @@ -256,6 +259,10 @@ ImU32 RowBackground(int kind)
return IM_COL32(84, 40, 40, 255);
case DEVIEW_ROW_MODIFIED:
return IM_COL32(74, 64, 32, 255);
/* A shade lighter than filler, so a fold reads as a break in the file rather than as a
* line of it or as padding. */
case DEVIEW_ROW_FOLDED:
return IM_COL32(34, 34, 34, 255);
default:
return 0;
}
Expand Down Expand Up @@ -540,6 +547,7 @@ int ReadKey()
if (IsKeyPressed(KEY_END)) return DEVIEW_KEY_END;
if (IsKeyPressed(KEY_N)) return DEVIEW_KEY_NEXT_CHANGE;
if (IsKeyPressed(KEY_P)) return DEVIEW_KEY_PREVIOUS_CHANGE;
if (IsKeyPressed(KEY_M)) return DEVIEW_KEY_TOGGLE_MINIMAL;
if (IsKeyPressed(KEY_TAB)) return IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)
? DEVIEW_KEY_PREVIOUS_ITEM
: DEVIEW_KEY_NEXT_ITEM;
Expand Down
1 change: 1 addition & 0 deletions native/swift/Sources/Deview/MainMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ enum MainMenu {
.separator(),
command("Next Change (n)", DEVIEW_KEY_NEXT_CHANGE, target),
command("Previous Change (p)", DEVIEW_KEY_PREVIOUS_CHANGE, target),
command("Toggle Changes Only (m)", DEVIEW_KEY_TOGGLE_MINIMAL, target),
.separator(),
command("Next Pending (⇥)", DEVIEW_KEY_NEXT_ITEM, target),
command("Previous Pending (⇧⇥)", DEVIEW_KEY_PREVIOUS_ITEM, target)
Expand Down
9 changes: 9 additions & 0 deletions native/swift/Sources/Deview/Palette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ enum Palette {

static let rule = grey(70)

/// Behind a folded row, a shade lighter than filler, so the runs the minimal view leaves out
/// read as breaks in the file rather than as a line of it or as padding.
static let folded = grey(34)

/// ImGui draws a selected item as its accent at 31% over the window background. This is that
/// composite, so the queue highlight matches without carrying an alpha channel around.
static let selected = rgb(38, 64, 90)
Expand Down Expand Up @@ -60,6 +64,9 @@ enum Palette {
return rgb(233, 129, 129)
case DEVIEW_ROW_MODIFIED.value:
return rgb(231, 197, 113)
// Dimmed like the gutter, since what it says is about the file rather than from it.
case DEVIEW_ROW_FOLDED.value:
return dim
default:
return text
}
Expand All @@ -76,6 +83,8 @@ enum Palette {
return rgb(74, 64, 32)
case DEVIEW_ROW_FILLER.value:
return filler
case DEVIEW_ROW_FOLDED.value:
return folded
default:
return nil
}
Expand Down
3 changes: 2 additions & 1 deletion native/swift/Sources/Deview/Renderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,8 @@ final class Renderer {
return
}

let number = String(row.lineNumber)
// A folded row has no number, and printing the -1 standing in for one put it in the gutter.
let number = row.lineNumber < 0 ? "" : String(row.lineNumber)
let gutter = "\(Palette.marker(row.kind)) \(String(repeating: " ", count: max(0, 4 - number.count)))\(number)"
let width = Renderer.gutterCells * cell.width

Expand Down
2 changes: 2 additions & 0 deletions native/swift/Sources/Deview/ViewerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,8 @@ final class ViewerView: NSView, NSViewToolTipOwner {
return DEVIEW_KEY_NEXT_CHANGE.value
case "p":
return DEVIEW_KEY_PREVIOUS_CHANGE.value
case "m":
return DEVIEW_KEY_TOGGLE_MINIMAL.value
case "a":
return shift ? DEVIEW_KEY_ACCEPT_ALL.value : DEVIEW_KEY_ACCEPT.value
case "d":
Expand Down
10 changes: 8 additions & 2 deletions src/DiffEngine.Tests/ViewerClientUnownedTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,14 @@ public class ViewerClientUnownedTests
static readonly ViewerMessage settle = new(ViewerVerb.Settle, InlineKey.For("Tests.cs", 1));

// Read before any test can have changed it, so the restore below puts back the real default
// rather than a copy of it kept here
static readonly TimeSpan recheckUnownedAfter = ViewerClient.RecheckUnownedAfter;
// rather than a copy of it kept here. A hook rather than a static field initializer: with no
// static constructor that runs on first touching a static field, and TheMemoryExpires sets the
// value before it touches one, so running first it captured Zero for every test after it.
static TimeSpan recheckUnownedAfter;

[Before(Class)]
public static void Remember() =>
recheckUnownedAfter = ViewerClient.RecheckUnownedAfter;

[Before(Test)]
public void Forget() =>
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
15 changes: 12 additions & 3 deletions src/DiffEngineViewer.Tests/AcceptAllProgressTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,10 @@ public async Task TheWindowOffersNothingThatChangesTheQueueWhileItRuns()
var screen = ScreenBuilder.Build(state);

await Assert.That(screen.Status).IsEqualTo("Accepting 1 of 3");
await Assert.That(screen.Buttons.Where(_ => _.Enabled)).IsEmpty();
await Assert.That(EnabledQueueButtons(screen)).IsEmpty();

var window = new Window();
foreach (var key in new[] { CommandKind.Accept, CommandKind.Discard, CommandKind.AcceptAll })
foreach (var key in queueCommands)
{
var pressed = ViewerProgram.Apply(state, Input(key), null, window);
await Assert.That(pressed.Queue).IsSameReferenceAs(state.Queue);
Expand All @@ -206,7 +206,7 @@ public async Task AnAttachedWindowShowsTheOwnersProgress()

var screen = ScreenBuilder.Build(state);
await Assert.That(screen.Status).IsEqualTo("Accepting 5 of 9");
await Assert.That(screen.Buttons.Where(_ => _.Enabled)).IsEmpty();
await Assert.That(EnabledQueueButtons(screen)).IsEmpty();

// And the listing after the batch is what gives the window back
var after = ViewerSession.Sync(state, Fixtures.Pending(Fixtures.Patch()), [], "Accepted 8", null);
Expand Down Expand Up @@ -359,6 +359,15 @@ static SessionState Pending() =>
Fixtures.Patch("SampleTests.cs", 88, "\"one\"", "two"),
Fixtures.Patch("OtherTests.cs", 12, null, "brand new"));

/// <summary>
/// What a batch refuses. Moving between changes and switching views only change what is being
/// read, so those buttons stay live while one runs.
/// </summary>
static readonly CommandKind[] queueCommands = [CommandKind.Accept, CommandKind.Discard, CommandKind.AcceptAll];

static IEnumerable<Button> EnabledQueueButtons(Screen screen) =>
screen.Buttons.Where(_ => _.Enabled && queueCommands.Contains(_.Command));

static ViewerInput Input(CommandKind key) =>
new(key, -1, -1, 0, false, Fixtures.Columns, Fixtures.Rows);

Expand Down
52 changes: 52 additions & 0 deletions src/DiffEngineViewer.Tests/DeviewStructTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ public async Task FieldsMatchTheHeader(string name, Type managed)
await Assert.That(string.Join(", ", Camel(mirrored))).IsEqualTo(string.Join(", ", declared));
}

/// <summary>
/// A row kind crosses the ABI as a cast rather than through a mapping, so the two enums have to
/// agree value for value. A kind added to one side only is drawn as whatever the other side has
/// at that number, and nothing fails anywhere.
/// </summary>
[Test]
public async Task RowKindsMatchTheHeader() =>
await Assert.That(Managed<RowKind>()).IsEqualTo(Declared("DeviewRowKind", "DEVIEW_ROW_"));

/// <summary>
/// Keys are mapped rather than cast, because the shim reports only the ones a window can
/// produce, but the numbers are still the wire format and both sides have to use the same.
/// </summary>
[Test]
public async Task KeysMatchTheHeader() =>
await Assert.That(Managed<DeviewKey>()).IsEqualTo(Declared("DeviewKey", "DEVIEW_KEY_"));

public static IEnumerable<(string, Type)> Structs()
{
yield return ("DeviewRow", typeof(DeviewRow));
Expand Down Expand Up @@ -83,6 +100,41 @@ static List<string> Fields(string name)
.ToList();
}

/// <summary>
/// An enum as name and value pairs in value order, the names lower cased and run together so
/// <c>ScrollUp</c> and <c>DEVIEW_KEY_SCROLL_UP</c> compare as the same member.
/// </summary>
static string Managed<T>()
where T : struct, Enum =>
string.Join(
", ",
Enum.GetValues<T>()
.OrderBy(_ => Convert.ToInt32(_))
.Select(_ => $"{_.ToString().ToLowerInvariant()}={Convert.ToInt32(_)}"));

static string Declared(string name, string prefix)
{
var block = Regex.Match(
Header(),
$@"enum {name} \{{(?<body>.*?)\}};",
RegexOptions.Singleline);
if (!block.Success)
{
throw new($"{name} is not declared in deview.h.");
}

var body = Regex.Replace(block.Groups["body"].Value, @"/\*.*?\*/", "", RegexOptions.Singleline);
return string.Join(
", ",
Regex
.Matches(body, @"(?<member>\w+)\s*=\s*(?<value>\d+)")
.Select(_ => (
Member: _.Groups["member"].Value[prefix.Length..].Replace("_", "").ToLowerInvariant(),
Value: int.Parse(_.Groups["value"].Value)))
.OrderBy(_ => _.Value)
.Select(_ => $"{_.Member}={_.Value}"));
}

static string Header()
{
// bin/{configuration}/{tfm} under this test project, so four up is src and five is the
Expand Down
80 changes: 80 additions & 0 deletions src/DiffEngineViewer.Tests/DiffViewTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/// <summary>
/// The mapping between a view's rows and the entry's, which scrolling, selection and switching
/// views all go through. Held as invariants over every row rather than as a few picked cases,
/// because an off by one here moves a selection by a line without anything looking wrong.
/// </summary>
public class DiffViewTests
{
[Test]
public async Task Every_row_of_the_entry_finds_the_row_that_shows_it()
{
var entry = Entry();
var view = entry.View(true);

for (var line = 0; line < entry.TotalRows; line++)
{
var row = view.Find(line);
await Assert.That(view.First(row)).IsLessThanOrEqualTo(line);
await Assert.That(view.Last(row)).IsGreaterThanOrEqualTo(line);
}
}

[Test]
public async Task A_shown_row_is_the_entrys_own()
{
var entry = Entry();
var view = entry.View(true);

for (var row = 0; row < view.Count; row++)
{
if (view.IsFolded(row))
{
continue;
}

await Assert.That(view.Left[row]).IsSameReferenceAs(entry.LeftRows[view.First(row)]);
await Assert.That(view.Right[row]).IsSameReferenceAs(entry.RightRows[view.First(row)]);
await Assert.That(view.Last(row)).IsEqualTo(view.First(row));
}
}

/// <summary>
/// Between them the rows cover the entry once each, the last one running to the end, so no
/// line is in two rows and none is in no row.
/// </summary>
[Test]
public async Task The_rows_cover_the_entry_exactly_once()
{
var entry = Entry();
var view = entry.View(true);

var next = 0;
for (var row = 0; row < view.Count; row++)
{
await Assert.That(view.First(row)).IsEqualTo(next);
next = view.Last(row) + 1;
}

await Assert.That(next).IsEqualTo(entry.TotalRows);
}

/// <summary>
/// Changes are never folded, so the minimal view has the same runs of them, only closer
/// together.
/// </summary>
[Test]
public async Task Both_views_have_the_same_changes()
{
var entry = Entry();
var full = entry.View(false);
var minimal = entry.View(true);

var mapped = minimal.Changes.Select(_ => minimal.First(_));

await Assert.That(string.Join(", ", mapped)).IsEqualTo(string.Join(", ", full.Changes));
await Assert.That(string.Join(", ", full.Changes)).IsEqualTo("2, 16, 32");
}

static QueueEntry Entry() =>
Fixtures.File(Fixtures.Long(true), Fixtures.Long(false)).Current!;
}
Loading
Loading