diff --git a/pygitweb/README.md b/pygitweb/README.md
index 74d7aca..550bcfd 100644
--- a/pygitweb/README.md
+++ b/pygitweb/README.md
@@ -57,12 +57,13 @@ See [ROUTES.md](ROUTES.md) for more detail.
 - `GET /{project}?a=tag&h=...` — single tag view (tag ref or hash)
 - `GET /{project}?a=commit` — commit information
 - `GET /{project}?a=commitdiff` — commit diff (unified diff rendered with [diff2html](https://github.com/rtfpessoa/diff2html))
-- `GET /{project}?a=commitdiff_plain` — unified diff for commit (raw text)
+- `GET /{project}?a=patch&h=...` — single-commit patch (plain text)
+- `GET /{project}?a=patches&h=...&hb=...` — multi-commit patches for range `hb..h` (plain text)
 - `GET /{project}?a=remotes` — list repo remotes
 
 **Project-scoped actions (stub only)**
 
-These actions are accepted but return a minimal placeholder page. Add handlers in `main.py` to implement: `blame`, `blame_incremental`, `blame_data`, `blobdiff`, `blobdiff_plain`, `patch`, `patches`, `rss`, `atom`, `search`, `search_help`, `snapshot`, `object`.
+These actions are accepted but return a minimal placeholder page. Add handlers in `main.py` to implement: `blame`, `blame_incremental`, `blame_data`, `blobdiff`, `blobdiff_plain`, `rss`, `atom`, `search`, `search_help`, `snapshot`, `object`.
 
 ## Query parameter short names (CGI mapping)
 
diff --git a/pygitweb/config.py b/pygitweb/config.py
index 5602c0b..53ef0a6 100644
--- a/pygitweb/config.py
+++ b/pygitweb/config.py
@@ -21,7 +21,6 @@ ACTIONS = {
     "blob",
     "blob_plain",
     "commitdiff",
-    "commitdiff_plain",
     "commit",
     "heads",
     "history",
diff --git a/pygitweb/git_helpers.py b/pygitweb/git_helpers.py
index 2c5bcf0..13876b7 100644
--- a/pygitweb/git_helpers.py
+++ b/pygitweb/git_helpers.py
@@ -515,3 +515,25 @@ def get_commit_history(
         return commits
     except (KeyError, pygit2.GitError, OSError):
         return []
+
+
+def get_commits_in_range(project: str, tip: str, base: str | None) -> list[str]:
+    """Return list of commit OIDs from tip back to (but not including) base. Newest first."""
+    try:
+        repo = open_repo(project)
+        tip_commit = repo.revparse_single(tip)
+        if not isinstance(tip_commit, pygit2.Commit):
+            return []
+        base_oid = None
+        if base:
+            try:
+                base_obj = repo.revparse_single(base)
+                base_oid = base_obj.id
+            except (KeyError, pygit2.GitError):
+                pass
+        walker = repo.walk(tip_commit.id, pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL)
+        if base_oid is not None:
+            walker.hide(base_oid)
+        return [str(c.id) for c in walker]
+    except (KeyError, pygit2.GitError, OSError):
+        return []
diff --git a/pygitweb/main.py b/pygitweb/main.py
index cd929b4..5ff8e7e 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -42,6 +42,7 @@ from pygitweb.formatting import age_string, esc_html, sanitize, to_utf8
 from pygitweb.git_helpers import (
     get_blob_at_ref_path,
     get_commit_history,
+    get_commits_in_range,
     get_tree_at_ref_path,
     git_get_head_hash,
     git_get_heads_list,
@@ -481,8 +482,10 @@ def dispatch(
         return git_tag(proj, hash_param)
     if action == "commit":
         return git_commit(proj, hash_param)
-    if action == "commitdiff_plain":
-        return git_commitdiff_plain(proj, hash_param)
+    if action == "patch":
+        return git_patch(proj, h)
+    if action == "patches":
+        return git_patches(proj, h, hb)
     if action == "commitdiff":
         return git_commitdiff(proj, hash_param)
     if action == "remotes":
@@ -750,6 +753,7 @@ def _format_commit_table_row(project: str, commit: dict[str, Any], short: bool =
         age_str = ""
     
     commit_link = f"/{project}?a=commit&h={quote(oid, safe='')}"
+    diff_link = f"/{project}?a=commitdiff&h={quote(oid, safe='')}"
     author_display = esc_html(author) or "unknown"
     
     if short:
@@ -758,7 +762,8 @@ def _format_commit_table_row(project: str, commit: dict[str, Any], short: bool =
             f'<a href="{commit_link}">{esc_html(oid_short)}</a>',
             esc_html(subject),
             author_display,
-            esc_html(age_str)
+            esc_html(age_str),
+            f'<a href="{diff_link}">diff</a>',
         ]
     else:
         # Full log: more details
@@ -767,7 +772,8 @@ def _format_commit_table_row(project: str, commit: dict[str, Any], short: bool =
             esc_html(subject),
             author_display,
             esc_html(date_str),
-            esc_html(age_str)
+            esc_html(age_str),
+            f'<a href="{diff_link}">diff</a>',
         ]
 
 
@@ -821,7 +827,7 @@ def git_log(project: str, h: str | None, request: Request, page: int, pagecount:
     title = f"Log - {esc_html(project)} @ {esc_html(ref_display)}"
     pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
     table = env.get_template("table.html").render(
-        cols=["Commit", "Subject", "Author", "Date", "Age"],
+        cols=["Commit", "Subject", "Author", "Date", "Age", "Diff"],
         rows=rows
     )
     pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
@@ -846,7 +852,7 @@ def git_shortlog(project: str, h: str | None, request: Request, page: int, pagec
     title = f"Shortlog - {esc_html(project)} @ {esc_html(ref_display)}"
     pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
     table = env.get_template("table.html").render(
-        cols=["Commit", "Subject", "Author", "Age"],
+        cols=["Commit", "Subject", "Author", "Age", "Diff"],
         rows=rows
     )
     pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
@@ -875,7 +881,7 @@ def git_history(project: str, h: str | None, f: str | None, request: Request, pa
     title = f"History - {esc_html(f)} - {esc_html(project)} @ {esc_html(ref_display)}"
     pre = PREAMBLE.render(title=title, site_name=config.SITE_NAME)
     table = env.get_template("table.html").render(
-        cols=["Commit", "Subject", "Author", "Date", "Age"],
+        cols=["Commit", "Subject", "Author", "Date", "Age", "Patch"],
         rows=rows
     )
     pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
@@ -1048,7 +1054,7 @@ def git_commit(project: str, h: str | None) -> HTMLResponse:
     commit_link = f"/{project}?a=commit&h={quote(oid, safe='')}"
     tree_link = f"/{project}?a=tree&h={quote(oid, safe='')}"
     diff_link = f"/{project}?a=commitdiff&h={quote(oid, safe='')}"
-    patch_link = f"/{project}?a=commitdiff_plain&h={quote(oid, safe='')}"
+    patch_link = f"/{project}?a=patch&h={quote(oid, safe='')}"
     table_rows = [
         ["commit", f'<a href="{commit_link}">{esc_html(oid)}</a>'],
         ["Author", f"{esc_html(author)} &lt;{esc_html(author_email)}&gt;"],
@@ -1110,12 +1116,41 @@ def git_commitdiff(project: str, h: str | None) -> HTMLResponse:
     return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 
 
-def git_commitdiff_plain(project: str, h: str | None) -> PlainTextResponse:
-    """Unified diff for a commit. Uses PyGit2 Diff; returns text/plain unified diff."""
-    body, _ = _get_commit_unified_diff(project, h or "")
+def git_patch(project: str, h: str | None) -> PlainTextResponse:
+    """Single-commit patch (plain text unified diff)."""
+    if not h:
+        raise HTTPException(status_code=400, detail="Commit hash (h) required")
+    body, oid_short = _get_commit_unified_diff(project, h)
+    filename = f"{project}-{oid_short}.patch"
+    return PlainTextResponse(
+        body,
+        media_type="text/x-diff; charset=utf-8",
+        headers={"Content-Disposition": f'inline; filename="{filename}"'},
+    )
+
+
+def git_patches(project: str, h: str | None, hb: str | None) -> PlainTextResponse:
+    """Multi-commit patches (plain text). Range hb..h if hb given, else single commit."""
+    if not h:
+        raise HTTPException(status_code=400, detail="Commit hash (h) required")
+    if hb:
+        oids = get_commits_in_range(project, h, hb)
+        if not oids:
+            raise HTTPException(status_code=404, detail="No commits in range")
+        parts = []
+        for oid in oids:
+            diff, _ = _get_commit_unified_diff(project, oid)
+            if diff:
+                parts.append(diff)
+        body = "\n".join(parts)
+        filename = f"{project}-{h[:7]}-patches.patch"
+    else:
+        body, oid_short = _get_commit_unified_diff(project, h)
+        filename = f"{project}-{oid_short}.patch"
     return PlainTextResponse(
         body,
         media_type="text/x-diff; charset=utf-8",
+        headers={"Content-Disposition": f'inline; filename="{filename}"'},
     )
 
 
