تُرجمت هذه الصفحة آليًا وقد تحتوي على أخطاء. اقرأ الأصل الإنجليزي

العناصر

DiffView

يعرض DiffView التغييرات بين نصين كما تفعل أدوات مراجعة الشيفرة: إما جنبًا إلى جنب، النص القديم على اليسار والجديد على اليمين مع محاذاة أسطرهما، أو في عرض موحّد تظهر فيه الأسطر المحذوفة فوق الأسطر المضافة. تُميَّز الكلمات التي تغيّرت داخل السطر المتغيّر، وتنطوي الأسطر غير المتغيّرة بين التغييرات في فواصل قابلة للتوسيع، ويُلوَّن النص بألوان صياغة Editor. يعرض نصين (oldValue وnewValue) أو ملفات diff موحّد (patch: ناتج git diff، أو طلب سحب)، كلٌّ منها تحت ترويسته، وهو مصمَّم لمراجعة التغييرات، ومنها التعديلات التي يقترحها الذكاء الاصطناعي: إجراءات التطبيق على كل تغيير تُبقيه أو تتراجع عنه. تُنشأ الصفوف الظاهرة فقط، لذا يتمرّر الملف الطويل كما يتمرّر القصير.

el-diff-view.tsx
el-diff-view.tsxLive
import { mount, signal, Text, View } from "nsh";
import { DiffView, type DiffViewRef } from "nsh/base";
import { button, buttonText, desktop, Log, Panel, Segmented, size, Split, ui, wide } from "./palette";

const OLD = `fn greet(name: &str) -> String {
    format!("Hello, {}", name)
}

fn main() {
    let names = vec!["Ada", "Grace"];
    for name in names {
        println!("{}", greet(name));
    }
}
`;

const NEW = `fn greet(name: &str) -> String {
    format!("Hello, {name}!")
}

fn main() {
    let names = ["Ada", "Grace", "Linus"];
    for name in names {
        println!("{}", greet(name));
    }
}
`;

// The texts are the app's: a change reverted or kept is reported
// (`onChange`) and set back as `oldValue` and `newValue`.
function App() {
  const layout = signal<"split" | "unified">(desktop ? "split" : "unified");
  const inline = signal<"wordAlt" | "char" | "none">("wordAlt");
  const oldValue = signal(OLD);
  const newValue = signal(NEW);
  const stats = signal("");
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 4));
  let diff: DiffViewRef | undefined;
  const count = async () => {
    const s = await diff?.stats();
    stats.set(s ? `+${s.added} −${s.deleted} · ${s.hunks} changes` : "");
  };

  const action = (label: string, onPress: () => void, primary?: boolean) => (
    <View onPress={onPress} role="button" accessibilityLabel={label} {...button(primary ? "accent" : "secondary", { size: "sm" })}>
      <Text style={buttonText(primary ? "accent" : "secondary")}>{label}</Text>
    </View>
  );

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>diff view</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Click Revert or Keep on a change, or a line number" : "Tap Revert or Keep on a change, or a line number"}
      </Text>

      <Split
        grow
        width={680}
        inspector={
          <>
            <Segmented label="layout" options={["split", "unified"] as const} value={layout()} onChange={(v) => layout.set(v)} />
            <Segmented label="inline" options={["wordAlt", "char", "none"] as const} value={inline()} onChange={(v) => inline.set(v)} />
            <Panel title="stats">
              <Text style={{ fontSize: 13, fontVariant: ["tabular-nums"], color: ui().text }}>{stats()}</Text>
              <View style={{ flexDirection: "row", gap: 8, marginTop: 6 }}>
                <View
                  onPress={() => {
                    oldValue.set(OLD);
                    newValue.set(NEW);
                    setTimeout(count, 0);
                  }}
                  role="button"
                  {...button("secondary", { size: "sm" })}
                >
                  <Text style={buttonText()}>Reset</Text>
                </View>
              </View>
            </Panel>
            <Log title="events" lines={events()} minHeight={96} />
          </>
        }
      >
        <View style={{ height: wide() ? 360 : 320, ...ui().card, borderRadius: 14, overflow: "hidden" }}>
          <DiffView
            ref={(r) => {
              diff = r;
              count();
            }}
            oldValue={oldValue()}
            newValue={newValue()}
            language="rust"
            layout={layout()}
            inline={inline()}
            contextLines={1}
            renderHunkActions={(hunk) => (
              <View style={{ flexDirection: "row", gap: 4 }}>
                {action("Revert", () => diff?.revertHunk(hunk.index))}
                {action("Keep", () => diff?.acceptHunk(hunk.index), true)}
              </View>
            )}
            onChange={({ change }) => {
              oldValue.set(change.oldValue);
              newValue.set(change.newValue);
              log(`onChange: ${change.newValue.split("\n").length - 1} lines`);
              count();
            }}
            onLineNumberPress={({ line }) => log(`onLineNumberPress: ${line.side} ${line.line + 1}`)}
            style={{ flex: 1 }}
          />
        </View>
      </Split>
    </View>
  );
}

mount(App);

يتولى DiffView السلوك، ويأتي بمظهر خاص به مبني على رموز (tokens) السمة: font-mono الخاص بالسمة بحجم 13pt في صفوف ارتفاعها 20pt، وأرقام الأسطر بـmuted-foreground، والأسطر المضافة بدرجة خضراء والمحذوفة بدرجة حمراء، والكلمات المتغيّرة بدرجة أقوى، وشريط عند حافة الهامش لكل سطر متغيّر، والفواصل على muted. يأخذ كل جزء أنماط التطبيق بدلًا من هذه القيم الافتراضية (classes وstyles). يُنشئ Rust واحدًا بـnsh_base::diff_view()، وTSX بـ<DiffView> من nsh/base.

use nsh_base::diff_view;
use nsh_core::prelude::*;

const OLD: &str = "fn total(prices: &[u32]) -> u32 {\n    prices.iter().sum()\n}\n";
const NEW: &str = "fn total(prices: &[u64]) -> u64 {\n    prices.iter().copied().sum()\n}\n";

fn review() -> impl IntoElement {
    diff_view().old_value(OLD).new_value(NEW).language("rust").flex_1()
}

يملأ العرض الصندوق الذي يمنحه التطبيق إياه (flex-1، وارتفاع) ويتمرّر داخله: عموديًا عبر الصفوف، وأفقيًا حتى أطول سطر، مع تمرّر جانبَي العرض المقسّم معًا. وعند استخدام softWrap تلتف الأسطر الطويلة عند عرض الجانب بدلًا من ذلك، ويكون الصف المقسّم بارتفاع أطول جانبيه، فيبقى الجانبان محاذيين.

#نصان أو رقعة

  • نصان: oldValue وnewValue. تُقارَن الأسطر كما هي، لذا فإضافة فاصل سطر أخير أو إزالته تُعدّ تغييرًا، كما يعرضه git.
  • رقعة (patch): patch، وهي diff موحّد لملف واحد أو عدة ملفات (git diff وgit format-patch وdiff -u؛ مع عمليات إعادة التسمية والأذونات والملفات الجديدة والمحذوفة والثنائية، ورسالة الإيداع التي تسبقها، وتُقرأ كما يكتبها git). يعرض العرض أسطر مقاطعها (hunks)، مرقّمة كما في الملف، كل مقطع تحت فاصل لترويسته (الدالة التي يقع فيها)، وكل ملف من عدة ملفات تحت ترويسة خاصة به (ملفات الرقعة). يحدد مسار كل ملف لغته عندما لا يكون language معيّنًا.
const PATCH = `diff --git a/src/total.rs b/src/total.rs
--- a/src/total.rs
+++ b/src/total.rs
@@ -10,3 +10,3 @@ impl Cart {
-    fn total(&self) -> u32 {
+    fn total(&self) -> u64 {
         self.prices.iter().sum()
     }
`;

<DiffView patch={PATCH} layout="unified" class="flex-1" />

يسمّي language اللغة كما يفعل Editor: باسم أو اسم مستعار أو امتداد أو اسم ملف ("rust"، "tsx"، "main.go"، "Dockerfile").

#ملفات الرقعة

تعرض الرقعة متعددة الملفات كل ملف تحت صف ترويسة: افتراضيًا مساره (old → new عند إعادة التسمية)، وعدد أسطره المضافة والمحذوفة، و▾، والضغط عليها يطوي الملف إلى ترويسته (▸) أو يوسّعه. يرسم renderFileHeader الترويسات بطريقة التطبيق (ويعرض واحدة فوق رقعة الملف الواحد أيضًا)، ويُعطى الملف (DiffFile): index، path، oldPath (أُعيدت تسميته أو نُسخ منه)، status ("modified"، "added"، "deleted"، "renamed"، "copied")، binary (لا تعرض الرقعة أيًا من أسطره)، added، deleted، hunks، firstHunk وcollapsed. تستدعي ترويسة التطبيق دالة toggleFile(path) الخاصة بالمرجع (ref)؛ ويضبط collapseFile وexpandFile الحالة، ويُبلَّغ عن كل تغيير بـonFileToggle({ file }). يبقى الملف المطوي مطويًا ما دام patch جديد ما زال يحتويه.

تُعدّ التغييرات عبر الملفات بالترتيب: تغييرات الملف هي من firstHunk إلى firstHunk + hunks - 1، وتستخدمها goToHunk وrenderHunkActions وhunks() (file الخاص بـDiffHunk هو فهرس ملفه، وتُحسب أسطره داخل ذلك الملف)، ويجمع stats() تغييرات كل الملفات. يوسّع goToHunk الملف المطوي أولًا.

let diff!: DiffViewRef;
const files = signal<DiffFile[]>([]);

<View class="flex-1 flex-row">
  <View class="w-48">
    <Each of={files()}>
      {(file) => (
        <Button onPress={() => diff.scrollToFile(file().path)} class="px-2 py-1">
          <Text class="text-sm">{file().path} +{file().added} −{file().deleted}</Text>
        </Button>
      )}
    </Each>
  </View>
  <DiffView
    ref={(r) => {
      diff = r;
      r.files().then((list) => files.set(list));
    }}
    patch={PULL_REQUEST}
    renderFileHeader={(file) => (
      <Button onPress={() => diff.toggleFile(file.path)} class="flex-1 flex-row items-center gap-2 bg-muted px-2">
        <Text>{file.collapsed ? "▸" : "▾"}</Text>
        <Text class="flex-1 text-sm font-medium">{file.oldPath ? `${file.oldPath} → ${file.path}` : file.path}</Text>
        <Text class="text-xs text-muted-foreground">{file.binary ? "Binary" : file.status}</Text>
      </Button>
    )}
    class="flex-1"
  />
</View>

#التخطيط والتغييرات داخل السطر

قيمة layout هي "split" (الافتراضي: جنبًا إلى جنب، كل سطر محذوف بجوار أكثر الأسطر المضافة شبهًا به) أو "unified" (عمود واحد، تظهر فيه الأسطر المحذوفة للتغيير فوق أسطره المضافة، مع رقمي السطر معًا). ويحدد inline ما يُميَّز داخل السطر المتغيّر:

inlineما يتم تمييزه
"wordAlt" (الافتراضي)الكلمات التي تغيّرت، وتُدمج عندما يفصل بينها حرف واحد، فتُقرأ العبارة المتغيّرة كوحدة واحدة
"word"كل كلمة تغيّرت
"char"كل حرف تغيّر
"none"لا شيء: درجات لون الأسطر وحدها

الكلمات هي سلاسل من الأحرف والأرقام و_؛ وكل حرف CJK ومقطع كانا أو هانغول يُعدّ كلمة بذاته، فيقتصر تمييز الحرف المتغيّر في جملة على نفسه. أما السطر الذي تغيّر بالكامل تقريبًا فيعرض درجة لونه وحدها.

يضع indicators علامة على الأسطر المتغيّرة بجوار أرقامها: "bars" (الافتراضي، شريط عند حافة الهامش)، أو "classic" (+ و− قبل النص)، أو "none". ويترك background={false} الأسطر المتغيّرة دون تلوين، ويخفي lineNumbers={false} الأرقام.

<DiffView oldValue={before()} newValue={after()} language="md" layout="unified" inline="word" indicators="classic" class="flex-1" />

#الأسطر المطوية

حول كل تغيير تظهر contextLines من الأسطر غير المتغيّرة (3 افتراضيًا)؛ أما الأسطر غير المتغيّرة بين التغييرات، وتلك التي قبل الأول وبعد الأخير، فتنطوي في فاصل يبيّن عدد الأسطر التي يخفيها. والضغط عليه يعرضها كلها. ويعرض expandAll كل الأسطر؛ وتكشفها expand وexpandAll() وcollapseAll() الخاصة بالمرجع وتطويها من التطبيق.

يرسم renderSeparator الفواصل بطريقة التطبيق. ويُعطى الفاصل (DiffSeparator): gap، وهو فهرس المقطع المطوي (0 قبل أول تغيير، وi بين التغييرين i - 1 وi)، وlines، وهو عدد الأسطر التي يخفيها، وfirst وlast، وللرقعة header الخاص بالمقطع. ويعرض expand(gap, direction, lines) "all" أسطره (الافتراضي)، أو lines (الافتراضي 20) "up"، فوق التغيير الواقع تحت الفاصل مباشرة، أو "down"، تحت التغيير الواقع فوقه مباشرة. يتوسّع الفاصل الأول للأعلى والأخير للأسفل.

let diff!: DiffViewRef;

<DiffView
  ref={diff}
  oldValue={before()}
  newValue={after()}
  language="ts"
  renderSeparator={(sep) => (
    <View class="flex-1 flex-row items-center gap-1 bg-muted px-2">
      <If when={!sep.first}>
        <Button onPress={() => diff.expand(sep.gap, "down")} class="px-1"><Text>↓</Text></Button>
      </If>
      <If when={!sep.last}>
        <Button onPress={() => diff.expand(sep.gap, "up")} class="px-1"><Text>↑</Text></Button>
      </If>
      <Button onPress={() => diff.expand(sep.gap)} class="flex-1">
        <Text class="text-xs text-muted-foreground">{sep.header ?? `${sep.lines} unchanged lines`}</Text>
      </Button>
    </View>
  )}
  class="flex-1"
/>

#الإبقاء على التغييرات والتراجع عنها

يضع renderHunkActions إجراءات التطبيق في أعلى يمين كل تغيير ظاهر، ويُعطى التغيير (DiffHunk: index، oldStart، oldLines، newStart، newLines، وتبدأ الأسطر من 0، وfile، وهو فهرس ملفه في الرقعة). وتستدعي هذه الإجراءات المرجع: يعيد revertHunk(index) الأسطر القديمة للتغيير إلى النص الجديد، ويجعل acceptHunk(index) أسطره الجديدة هي أسطر النص القديم. يعرض العرض النتيجة فورًا ويبلّغ عن النصين معًا بـonChange({ change }) ({ oldValue, newValue })، ويضبطهما التطبيق بوصفهما props. مراجعة تعديلات الذكاء الاصطناعي بالإبقاء عليها أو التراجع عنها تغييرًا بتغيير تبدو هكذا:

import { signal, Text, View } from "nsh";
import { Button, DiffView, type DiffViewRef } from "nsh/base";

function Proposal(props: { file: string; current: string; proposed: string; onApply: (text: string) => void }) {
  let diff!: DiffViewRef;
  const current = signal(props.current);
  const proposed = signal(props.proposed);
  return (
    <View class="flex-1">
      <DiffView
        ref={diff}
        oldValue={current()}
        newValue={proposed()}
        language={props.file}
        onChange={({ change: { oldValue, newValue } }) => {
          current.set(oldValue);
          proposed.set(newValue);
          props.onApply(oldValue);
        }}
        renderHunkActions={(hunk) => (
          <View class="flex-row gap-1">
            <Button onPress={() => diff.revertHunk(hunk.index)} class="rounded bg-background px-2 py-0.5 border border-border">
              <Text class="text-xs">Revert</Text>
            </Button>
            <Button onPress={() => diff.acceptHunk(hunk.index)} class="rounded bg-primary px-2 py-0.5">
              <Text class="text-xs text-primary-foreground">Keep</Text>
            </Button>
          </View>
        )}
        class="flex-1"
      />
    </View>
  );
}

هنا النص القديم هو الملف كما هو: الإبقاء على تغيير يطبّقه على الملف (onApply)، والتراجع عنه يزيله من الاقتراح. أما العرض الذي يعرض patch فيعرض مقتطفات من الملفات، وتبقى تغييراته كما هي.

#الدوال

يوفّر ref={diff} دوال العرض (DiffViewRef). الدوال التي تُرجع قيمة (hunks()، stats()) تُرجع Promise للقيمة في JS، لذا استخدم await معها: const changes = await diff.hunks(). وبالنسبة إلى الأعداد المعروضة على الشاشة، يبلّغ onStatsChange عنها كلما تغيّرت. وأي دالة تُعطى تغييرًا أو فاصلًا أو ملفًا لا يوجد في diff لا تغيّر شيئًا وتسجّل تحذيرًا يسمّي الدالة والقيمة.

الدالةالوصف
hunks()Promise<DiffHunk[]>: التغييرات بالترتيب.
stats()Promise<DiffStats>: { added, deleted, hunks }، والأسطر المضافة والمحذوفة وعدد التغييرات.
goToHunk(index)يمرّر التغيير index إلى مجال الرؤية، بحيث يكون صفه الأول في الأعلى.
revertHunk(index)يعيد الأسطر القديمة للتغيير index إلى النص الجديد ويبلّغ عن النصين (onChange).
acceptHunk(index)يجعل الأسطر الجديدة للتغيير index هي أسطر النص القديم ويبلّغ عن النصين (onChange).
expand(gap, direction?, lines?)يعرض الأسطر التي يخفيها الفاصل gap: "all" (الافتراضي)، أو lines (الافتراضي 20) "up" أو "down".
expandAll()يعرض كل سطر غير متغيّر.
collapseAll()يطوي الأسطر غير المتغيّرة مجددًا.
nextHunk()Promise<number | null>: يمرّر التغيير التالي إلى الأعلى ويعطي فهرسه (بعد الأخير يعود إلى الأول).
previousHunk()Promise<number | null>: يمرّر التغيير السابق إلى الأعلى ويعطي فهرسه (قبل الأول يعود إلى الأخير).
currentHunk()Promise<number | null>: التغيير الذي انتُقل إليه آخرًا ما دام العرض باقيًا عنده، وإلا فالتغيير الموجود أعلى العرض أو أول تغيير ظاهر.
selectedLines()Promise<DiffLineRange | null>: الأسطر المحددة على أرقام الأسطر.
setSelectedLines(range)يحدد أسطرًا من أحد الجانبين (null: لا شيء)، دون onLineSelectionChange.
selectedText()Promise<string>: نص الأسطر المحددة.
files()Promise<DiffFile[]>: ملفات الرقعة بالترتيب مع أعدادها (لا شيء في حالة نصين).
scrollToFile(path)يمرّر ترويسة الملف عند path إلى الأعلى.
collapseFile(path)يعرض ترويسة الملف عند path فقط.
expandFile(path)يعرض أسطر الملف عند path مجددًا.
toggleFile(path)يطوي الملف عند path، أو يوسّعه إذا كان مطويًا.
hunkBounds()Promise<DiffHunkBounds[]>: موضع كل تغيير في الصفوف بالترتيب (مسطرة النظرة العامة).
scrollOffset()Promise<Point>: مدى تمرّر الصفوف (x يساوي 0).
setScrollOffset(offset)يمرّر الصفوف إلى offset.y فورًا، بقدر ما تسمح به، كما يتبع المقبض المؤشر الذي يسحبه.

يكمل nextHunk() وpreviousHunk() من التغيير الذي انتقلا إليه ما دام العرض باقيًا عنده، وإلا فمن أعلى العرض، لذا يظل التغيير القريب من النهاية الذي لا يمكن تمريره إلى الأعلى محسوبًا. أزرار التغيير التالي والسابق، مع الأعداد من onStatsChange:

let diff!: DiffViewRef;
const stats = signal<DiffStats>({ added: 0, deleted: 0, hunks: 0 });

<View class="flex-row items-center gap-2 p-2">
  <Text class="text-sm text-green-600">+{stats().added}</Text>
  <Text class="text-sm text-red-600">−{stats().deleted}</Text>
  <Text class="text-sm text-muted-foreground">{stats().hunks} changes</Text>
  <Button onPress={() => diff.previousHunk()}><Text>Previous</Text></Button>
  <Button onPress={() => diff.nextHunk()}><Text>Next</Text></Button>
</View>
<DiffView ref={diff} oldValue={before()} newValue={after()} onStatsChange={(e) => stats.set(e.stats)} class="flex-1" />

#مسطرة النظرة العامة

يبلّغ onScroll({ offset, contentSize, size }) عن موضع الصفوف، بمفاتيح Scroll نفسها: offset.y، وهو مدى تمرّرها (تتمرّر الصفوف عموديًا بعرض العرض، وتتمرّر الجوانب أفقيًا بمفردها: offset.x يساوي 0)، وcontentSize، وهو حجم كل الصفوف معًا، وsize، وهو حجم منفذ العرض. يُستدعى مرة بعد تخطيط العرض ثم كلما تغيّر أحدها: عند تمرّر الصفوف، وعند تغيّر حجم العرض، وعند تغيّر ارتفاع الصفوف (نصوص أخرى، layout، softWrap، أو توسيع ملف أو فاصل أو طيّه، أو تخطيط تعليق توضيحي). يعطي hunkBounds() موضع كل تغيير في الصفوف (DiffHunkBounds: { index, bounds }، وbounds من أعلى صفه الأول إلى أسفل صفه الأخير، مع التعليقات التوضيحية تحتها، عبر العرض)، أي التغييرات الظاهرة: ولا صفوف لتغييرات الملف المطوي. ويعطي onHunkBoundsChange({ hunkBounds }) القائمة نفسها بعد تخطيط العرض ثم كلما تحرّك تغيير أو تغيّر حجمه، حتى لو حافظت الصفوف على ارتفاعها (سطر متغيّر يصبح سطرًا آخر): وترسم المسطرة علاماتها منها بدلًا من قراءتها على onScroll. ويقرأ scrollOffset() مدى تمرّر الصفوف، ويمرّر setScrollOffset({ x, y }) الصفوف إلى y فورًا، فيتبع المقبض المسحوب المؤشر في الإطار نفسه.

تُكوّن هذه معًا مسطرة نظرة عامة بجوار العرض، كما في محررات الشيفرة: علامة لكل تغيير، ومقبضًا فوق الصفوف الظاهرة، وضغطة أو سحبة تمرّر إلى هناك. هنا المسطرة بارتفاع العرض نفسه:

use nsh_base::{DiffHunkBounds, DiffView, DiffViewApi, DiffViewEvent, diff_view, theme};
use nsh_core::prelude::*;

fn review(old: SharedString, new: SharedString) -> impl IntoElement {
    let diff = component_ref::<DiffView>();
    let ruler = element_ref();
    let ruler_top = stored(px(0.0));
    // How far the rows are scrolled, their height and the viewport's.
    let rows = signal((px(0.0), px(0.0), px(0.0)));
    let marks = signal(Vec::<DiffHunkBounds>::new());
    // Points of the rows per point of the ruler.
    let scale = move || {
        let (_, height, view) = rows.get();
        height.max(px(1.0)) / view.max(px(1.0))
    };
    // The thumb's middle at the pointer.
    let follow = move |page_y: Points| {
        let view = rows.get_untracked().2;
        let y = (page_y - ruler_top.get()) * scale() - view / 2.0;
        if let Some(api) = diff.api::<DiffViewApi>() {
            api.set_scroll_offset(point(px(0.0), y));
        }
    };
    view()
        .flex_1()
        .flex_row()
        .child(
            diff_view()
                .old_value(old)
                .new_value(new)
                .on_scroll(move |e: &DiffViewEvent| {
                    if let DiffViewEvent::Scroll(offset, content, size) = e {
                        rows.set((offset.y, content.height, size.height));
                    }
                })
                .on_hunk_bounds_change(move |list: &Vec<DiffHunkBounds>| marks.set(list.clone()))
                .component_ref(&diff)
                .flex_1(),
        )
        .child(
            view()
                .element_ref(&ruler)
                .w(px(12.0))
                .bg(move || theme().muted)
                .claim_touches(TouchClaim::on_start())
                .on_responder_grant(move |e: &ResponderEvent| {
                    let y = e.page.y;
                    spawn(async move {
                        ruler_top.replace(ruler.measure().await.map_or(px(0.0), |m| m.page.y));
                        follow(y);
                    });
                })
                .on_responder_move(move |e: &ResponderEvent| follow(e.page.y))
                .child(each(
                    marks,
                    |mark: &DiffHunkBounds| mark.index,
                    move |mark, _| {
                        view()
                            .absolute()
                            .left(px(0.0))
                            .right(px(0.0))
                            .bg(rgb(0xf59e0b))
                            .top(move || mark.get().bounds.origin.y / scale())
                            .h(move || (mark.get().bounds.size.height / scale()).max(px(2.0)))
                    },
                ))
                .child(
                    view()
                        .absolute()
                        .left(px(0.0))
                        .right(px(0.0))
                        .bg(move || theme().foreground.opacity(0.2))
                        .top(move || rows.get().0 / scale())
                        .h(move || rows.get().2 / scale()),
                ),
        )
}

يأتي index العلامة من التغيير في hunks()، وبـoldLines وnewLines فيه يُميَّز التغيير المضاف (بلا أسطر قديمة) عن المحذوف (بلا أسطر جديدة) لتلوين علامته.

#تعليقات المراجعة

تحدد أرقام الأسطر أسطرًا من أحد الجانبين: الضغط يحدد سطرًا واحدًا، والضغط مع Shift يمدّ التحديد إليه، وسحب الفأرة فوق الأرقام يحدد ما يمرّ عليه. ويبلّغ onLineSelectionChange عن الأسطر بوصفها range (DiffLineRange: { side, start, end }، وتبدأ الأسطر من 0، وnull لعدم وجود تحديد)، ويبلّغ الضغط على رقم أيضًا عن onLineNumberPress({ line }) ({ side, line }). ويقرأ المرجع التحديد (selectedLines()، selectedText()) ويضبطه.

يضع annotations محتوى التطبيق تحت الأسطر: يرسم renderAnnotation كل { id, side, line } على امتداد جانب سطره (العرض كله في العرض الموحّد)، بالارتفاع الذي يُخطَّط به، ويظهر ما دام سطره ظاهرًا. وrenderAddAnnotation هو عنصر التحكم الذي يوفّره التطبيق لإضافة واحد، عند يمين هامش السطر الذي يمرّ عليه المؤشر وآخر سطر محدد؛ ويُعطى الأسطر المحددة عندما يكون سطره أحدها، وإلا فذلك السطر.

type Comment = { id: string; side: "old" | "new"; line: number; text: string };
const comments = signal<Comment[]>([]);
const drafting = signal<DiffLineRange | null>(null);

<DiffView
  oldValue={before()}
  newValue={after()}
  annotations={comments().map(({ id, side, line }) => ({ id, side, line }))}
  renderAnnotation={(a) => (
    <View class="m-2 rounded-md border border-border bg-card p-2">
      <Text class="text-sm">{comments().find((c) => c.id === a.id)?.text}</Text>
    </View>
  )}
  renderAddAnnotation={(range) => (
    <Button onPress={() => drafting.set(range)} class="size-4 items-center justify-center rounded bg-primary">
      <Text class="text-xs text-primary-foreground">+</Text>
    </Button>
  )}
  class="flex-1"
/>

#تنسيق الأجزاء

class وstyle ينسّقان صندوق العرض. classes يقبل فئات Tailwind حسب الجزء، وstyles يقبل أنماطًا مضمّنة حسب الجزء؛ فئات الجزء تأتي بعد أنماطه الافتراضية، لذا تتغلب عليها، وأنماطه styles تتغلب على فئاته. يُضبط الخط عبر fontFamily وfontSize وlineHeight.

<DiffView
  oldValue={before()}
  newValue={after()}
  language="py"
  fontSize={12}
  lineHeight={18}
  class="flex-1 rounded-lg border border-border"
  classes={{
    gutter: "bg-muted",
    lineAdded: "bg-blue-500/10",
    wordAdded: "bg-blue-500/30",
    indicatorAdded: "bg-blue-500",
    separator: "bg-transparent",
  }}
/>
الجزءما هوالافتراضي
sideكل جانب (split) أو العمود الواحد (unified)خط border بين الجانبين
gutterأرقام أسطر الجانب ومؤشراته
lineNumberكل رقم سطرمحاذاة لليمين، muted-foreground
lineNumberAddedأرقام الأسطر المضافة، بعد lineNumber
lineNumberDeletedأرقام الأسطر المحذوفة، بعد lineNumber
contentنص الجانب، بجوار هامشه8pt قبل النص وبعده
lineكل صف في الجانب
lineAddedصفوف الأسطر المضافة، بعد lineتظليل أخضر
lineDeletedصفوف الأسطر المحذوفة، بعد lineتظليل أحمر
fillerالنصف الفارغ من الصف المقسوم بجوار سطر يفتقر إليه الجانب الآخرتعبئة muted خافتة
wordAddedالكلمات المتغيرة داخل سطر مضافأخضر أقوى
wordDeletedالكلمات المتغيرة داخل سطر محذوفأحمر أقوى
indicatorAddedشريط السطر المضاف ("bars") أو + ("classic")أخضر، بعرض 3pt
indicatorDeletedشريط السطر المحذوف أو −أحمر، بعرض 3pt
separatorفاصل الأسطر المطوية، أو ترويسة hunk في patch⋯ والعدد على muted
hunkActionsصندوق إجراءات التطبيق على تغييرفي أعلى يمين التغيير
lineSelectedفوق صفوف الأسطر المحددة في جانبهالون التحديد في السمة (كتحديدات Editor)
annotationصندوق التعليق التوضيحي، تحت سطرهبعرض جانب سطره
addAnnotationصندوق عنصر الإضافة، بعرض الهامش وارتفاع سطر واحدعنصر التحكم عند يمينه
fileHeaderترويسة ملف في patchالمسار والأعداد على secondary، وخط border أسفلها

#الخصائص

الخاصيةRustالنوعالافتراضيالوصف
oldValue.old_value(..)stringالنص القديم.
newValue.new_value(..)stringالنص الجديد.
patch.patch(..)stringdiff موحّد لملف واحد أو عدة ملفات، يُعرض عندما لا يُضبط oldValue وnewValue.
language.language(..)stringنص عادي (في patch: لغة كل ملف)اسم لغة أو اسمها المستعار أو امتدادها أو اسم ملف.
layout.layout(..)"split" | "unified""split"جنبًا إلى جنب أو عمود واحد.
inline.inline(..)"wordAlt" | "word" | "char" | "none""wordAlt"ما يُميَّز داخل الأسطر المتغيرة.
indicators.indicators(..)"bars" | "classic" | "none""bars"كيف تُعلَّم الأسطر المتغيرة بجوار أرقامها.
contextLines.context_lines(..)number3الأسطر غير المتغيرة المعروضة حول كل تغيير.
expandAll.expand_all(..)booleanfalseتظهر كل الأسطر غير المتغيرة.
lineNumbers.line_numbers(..)booleantrueأرقام الأسطر.
background.background(..)booleantrueتُظلَّل الأسطر المتغيرة.
fontFamily.font_family(..)stringfont-mono السمةخط النص.
fontSize.font_size(..)number13حجم الخط بالنقاط.
lineHeight.line_height(..)number20ارتفاع الصف بالنقاط.
renderSeparator.render_separator(..)(separator: DiffSeparator) => JSX.Elementيرسم فواصل الأسطر المطوية.
renderHunkActions.render_hunk_actions(..)(hunk: DiffHunk) => JSX.Elementإجراءات التطبيق على كل تغيير.
softWrap.soft_wrap(..)booleanfalseتلتف الأسطر الطويلة عند عرض الجانب.
annotations.annotations(..)DiffAnnotation[][]محتوى التطبيق تحت الأسطر: { id, side, line }.
renderAnnotation.render_annotation(..)(annotation: DiffAnnotation) => JSX.Elementيرسم تعليقًا توضيحيًا تحت سطره.
renderAddAnnotation.render_add_annotation(..)(range: DiffLineRange) => JSX.Elementعنصر التحكم الخاص بالتطبيق لإضافة تعليق توضيحي، بجوار السطر الذي يحوم عليه المؤشر وآخر سطر محدد.
renderFileHeader.render_file_header(..)(file: DiffFile) => JSX.Elementيرسم ترويسة كل ملف في patch، بارتفاع صف واحد.
classes.classes(..)DiffViewClasses{}فئات Tailwind حسب الجزء.
styles.styles(..)DiffViewStyles{}أنماط مضمّنة حسب الجزء، تتغلب على الفئات.
class، styleدوال التنسيقstring، StyleProp<ViewStyle>صندوق العرض.

#الأحداث

الحدثRustالوصف
onChange.on_change(|change: &DiffChange| ..)تم الاحتفاظ بتغيير أو التراجع عنه: { change }، والنصوص الحالية ({ oldValue, newValue }).
onLineNumberPress.on_line_number_press(|line: &DiffLine| ..)تم الضغط على رقم سطر: { line }، وهو { side, line }.
onLineSelectionChange.on_line_selection_change(|range: &Option<DiffLineRange>| ..)تغيرت الأسطر المحددة: { range }، وهو { side, start, end } أو null.
onStatsChange.on_stats_change(|stats: &DiffStats| ..)تغيرت الأعداد: { stats }، وهو { added, deleted, hunks }. يُطلق مرة عند إنشاء العرض ومرة أخرى كلما قورنت النصوص فنتجت أعداد أخرى، لعرض سطر مثل “+8 −8 · 5 changes”.
onFileToggle.on_file_toggle(|file: &DiffFile| ..)طُوي ملف من patch أو وُسّع (ترويسته، أو المرجع ref): { file }، وDiffFile الآن.
onScroll.on_scroll(|e: &DiffViewEvent| ..)تم تمرير الصفوف، أو تغير حجمها أو حجم منطقة العرض: { offset, contentSize, size }، كما في Scroll (DiffViewEvent::Scroll(offset, content_size, size) في Rust). يُطلق عند اكتمال تخطيط العرض ومرة أخرى عند تغير أحدها.
onHunkBoundsChange.on_hunk_bounds_change(|bounds: &Vec<DiffHunkBounds>| ..)تغير موضع التغييرات بين الصفوف: { hunkBounds }، وهي القائمة التي يعطيها hunkBounds(). يُطلق عند اكتمال تخطيط العرض ومرة أخرى عند انتقال تغيير أو تغير حجمه، سواء بقي ارتفاع الصفوف كما هو أم لا، وذلك لعلامات مسطرة النظرة العامة.

#ذات صلة

  • Editor: محرر الشيفرة الذي يستخدم DiffView ألوان الصياغة ولغاته.
  • TextView: مستندات Markdown وHTML، للنص المحيط بالمراجعة.
  • nsh/base: المكونات الأساسية الأخرى.