Repository: coderamp-labs/gitingest

Issue:
feat: expose public API to fetch repository digest via URL & GitHub token
This is something I want to do, feel free to tell me what you want to see / do with this API 
feat: Improve swagger UI and backend endpoints
# topic

Doc: 
1. Swagger ui should be integrated within the main theme of gitingest.
2. Add a link to the navbar

Api: 

1. `GET api.xxx.com/{user}/repo`
2. `POST api.xxx.com/ingest`
3. `GET/POST/HEAD/DELETE api.xxx.com/` -> `openapi.json`

Jinja:

1. Move the HTML within utils.js to jinja and move the logic to switch visibility of the result block and inject results.
bug(ui): selected/marked files not affected when changing between Exclude and Include pattern
The selected/marked files should swap when changing between `Exclude` and `Include` patterns as seen below. This is currently broken on the `main` branch.

![Image](https://github.com/user-attachments/assets/91021a98-29a9-4a44-ba1c-e392489a3ec2)
-------
-------
-------
-------
![Image](https://github.com/user-attachments/assets/8d2266fd-3a84-4a34-a0bf-51aea9df8822)


1. When we select files to include/exclude, the change pattern does not correctly update the frontend
2. When we ingest with include/exclude files, they're not included in the tree provided by the response (cf: screen from ix-56h)

bug: download feature download the cropped context instead of the full one
# Subject
The download feature (on main branch) download the cropped context instead of the full one.

This is because of the backend refactoring where i did a bit of refactoring on the frontend side and removed the download endpoint, leading to downloading the context in the endpoint response and not the cached full context on the server.


**Note: this issue will be fixed once #366 is merged, i create it only for tracking issues/progress.


Discussion:
Hi @cyclotruc, this is a great product. I love the design. 

In principle, from the API I would like to be able to pass a repository link and a github token and retrieve a single string with all the context. 
This would be very helpful with agents and also for example prompts - you can write "fetch `url-here` to learn about this project" so it will use e.g. CURL to read the data before it starts and will understand how to properly use the project. That way you do not need to copy-paste the output from gitingest.



I'll fix this.

And also i think there's an issue with the download feature, we probably download the crop version instead of the full version but i need to double check. Hopefully it's not on prod for now.
adressed in #366 





Patch:
diff --git a/src/server/main.py b/src/server/main.py
index 8746923b..09904256 100644
--- a/src/server/main.py
+++ b/src/server/main.py
@@ -6,20 +6,21 @@
 from pathlib import Path
 
 from dotenv import load_dotenv
-from fastapi import FastAPI
-from fastapi.responses import FileResponse, HTMLResponse
+from fastapi import FastAPI, Request
+from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
 from fastapi.staticfiles import StaticFiles
 from slowapi.errors import RateLimitExceeded
 from starlette.middleware.trustedhost import TrustedHostMiddleware
 
 from server.routers import dynamic, index, ingest
+from server.server_config import templates
 from server.server_utils import lifespan, limiter, rate_limit_exception_handler
 
 # Load environment variables from .env file
 load_dotenv()
 
 # Initialize the FastAPI application with lifespan
-app = FastAPI(lifespan=lifespan)
+app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None)
 app.state.limiter = limiter
 
 # Register the custom exception handler for rate limits
@@ -48,10 +49,9 @@
 async def health_check() -> dict[str, str]:
     """Health check endpoint to verify that the server is running.
 
-    Returns
-    -------
-    dict[str, str]
-        A JSON object with a "status" key indicating the server's health status.
+    **Returns**
+
+    - **dict[str, str]**: A JSON object with a "status" key indicating the server's health status.
 
     """
     return {"status": "healthy"}
@@ -61,12 +61,13 @@ async def health_check() -> dict[str, str]:
 async def head_root() -> HTMLResponse:
     """Respond to HTTP HEAD requests for the root URL.
 
-    Mirrors the headers and status code of the index page.
+    **This endpoint mirrors the headers and status code of the index page**
+    for HTTP HEAD requests, providing a lightweight way to check if the server
+    is responding without downloading the full page content.
+
+    **Returns**
 
-    Returns
-    -------
-    HTMLResponse
-        An empty HTML response with appropriate headers.
+    - **HTMLResponse**: An empty HTML response with appropriate headers
 
     """
     return HTMLResponse(content=None, headers={"content-type": "text/html; charset=utf-8"})
@@ -74,12 +75,15 @@ async def head_root() -> HTMLResponse:
 
 @app.get("/robots.txt", include_in_schema=False)
 async def robots() -> FileResponse:
-    """Serve the ``robots.txt`` file to guide search engine crawlers.
+    """Serve the robots.txt file to guide search engine crawlers.
+
+    **This endpoint serves the ``robots.txt`` file located in the static directory**
+    to provide instructions to search engine crawlers about which parts of the site
+    they should or should not index.
+
+    **Returns**
 
-    Returns
-    -------
-    FileResponse
-        The ``robots.txt`` file located in the static directory.
+    - **FileResponse**: The ``robots.txt`` file located in the static directory
 
     """
     return FileResponse("static/robots.txt")
@@ -87,17 +91,73 @@ async def robots() -> FileResponse:
 
 @app.get("/llms.txt")
 async def llm_txt() -> FileResponse:
-    """Serve the ``llms.txt`` file to provide information about the site to LLMs.
+    """Serve the llm.txt file to provide information about the site to LLMs.
 
-    Returns
-    -------
-    FileResponse
-        The ``llms.txt`` file located in the static directory.
+    **This endpoint serves the ``llms.txt`` file located in the static directory**
+    to provide information about the site to Large Language Models (LLMs)
+    and other AI systems that may be crawling the site.
+
+    **Returns**
+
+    - **FileResponse**: The ``llms.txt`` file located in the static directory
 
     """
     return FileResponse("static/llms.txt")
 
 
+@app.get("/docs", response_class=HTMLResponse, include_in_schema=False)
+async def custom_swagger_ui(request: Request) -> HTMLResponse:
+    """Serve custom Swagger UI documentation.
+
+    **This endpoint serves a custom Swagger UI interface**
+    for the API documentation, providing an interactive way to explore
+    and test the available endpoints.
+
+    **Parameters**
+
+    - **request** (`Request`): The incoming HTTP request
+
+    **Returns**
+
+    - **HTMLResponse**: Custom Swagger UI documentation page
+
+    """
+    return templates.TemplateResponse("swagger_ui.jinja", {"request": request})
+
+
+@app.get("/api", include_in_schema=True)
+def openapi_json_get() -> JSONResponse:
+    """Return the OpenAPI schema.
+
+    **This endpoint returns the OpenAPI schema (openapi.json)**
+    that describes the API structure, endpoints, and data models
+    for documentation and client generation purposes.
+
+    **Returns**
+
+    - **JSONResponse**: The OpenAPI schema as JSON
+
+    """
+    return JSONResponse(app.openapi())
+
+
+@app.api_route("/api", methods=["POST", "PUT", "DELETE", "OPTIONS", "HEAD"], include_in_schema=False)
+@app.api_route("/api/", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"], include_in_schema=False)
+def openapi_json() -> JSONResponse:
+    """Return the OpenAPI schema for various HTTP methods.
+
+    **This endpoint returns the OpenAPI schema (openapi.json)**
+    for multiple HTTP methods, providing API documentation
+    for clients that may use different request methods.
+
+    **Returns**
+
+    - **JSONResponse**: The OpenAPI schema as JSON
+
+    """
+    return JSONResponse(app.openapi())
+
+
 # Include routers for modular endpoints
 app.include_router(index)
 app.include_router(ingest)
diff --git a/src/server/models.py b/src/server/models.py
index 1e6d14e5..a6e71edc 100644
--- a/src/server/models.py
+++ b/src/server/models.py
@@ -69,6 +69,8 @@ class IngestSuccessResponse(BaseModel):
         Short form of repository URL (user/repo).
     summary : str
         Summary of the ingestion process including token estimates.
+    ingest_id : str
+        Ingestion id used to download full context.
     tree : str
         File tree structure of the repository.
     content : str
@@ -85,6 +87,7 @@ class IngestSuccessResponse(BaseModel):
     repo_url: str = Field(..., description="Original repository URL")
     short_repo_url: str = Field(..., description="Short repository URL (user/repo)")
     summary: str = Field(..., description="Ingestion summary with token estimates")
+    ingest_id: str = Field(..., description="Ingestion id used to download full context")
     tree: str = Field(..., description="File tree structure")
     content: str = Field(..., description="Processed file content")
     default_max_file_size: int = Field(..., description="File size slider position used")
@@ -99,13 +102,10 @@ class IngestErrorResponse(BaseModel):
     ----------
     error : str
         Error message describing what went wrong.
-    repo_url : str
-        The repository URL that failed to process.
 
     """
 
     error: str = Field(..., description="Error message")
-    repo_url: str = Field(..., description="Repository URL that failed")
 
 
 # Union type for API responses
diff --git a/src/server/query_processor.py b/src/server/query_processor.py
index c5a15e8e..8513426b 100644
--- a/src/server/query_processor.py
+++ b/src/server/query_processor.py
@@ -99,7 +99,7 @@ async def process_query(
             print(f"{Colors.BROWN}WARN{Colors.END}: {Colors.RED}<-  {Colors.END}", end="")
             print(f"{Colors.RED}{exc}{Colors.END}")
 
-        return IngestErrorResponse(error=str(exc), repo_url=short_repo_url)
+        return IngestErrorResponse(error=str(exc))
 
     if len(content) > MAX_DISPLAY_SIZE:
         content = (
@@ -122,6 +122,7 @@ async def process_query(
         repo_url=input_text,
         short_repo_url=short_repo_url,
         summary=summary,
+        ingest_id=query.id,
         tree=tree,
         content=content,
         default_max_file_size=slider_position,
diff --git a/src/server/routers/download.py b/src/server/routers/download.py
deleted file mode 100644
index 2b7503bb..00000000
--- a/src/server/routers/download.py
+++ /dev/null
@@ -1,49 +0,0 @@
-"""Module containing the FastAPI router for downloading a digest file."""
-
-from fastapi import APIRouter, HTTPException
-from fastapi.responses import FileResponse
-from starlette.status import HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND
-
-from gitingest.config import TMP_BASE_PATH
-
-router = APIRouter()
-
-
-@router.get("/download/{digest_id}", response_class=FileResponse)
-async def download_ingest(digest_id: str) -> FileResponse:
-    """Return the first ``*.txt`` file produced for ``digest_id`` as a download.
-
-    Parameters
-    ----------
-    digest_id : str
-        Identifier that the ingest step emitted (also the directory name that stores the artefacts).
-
-    Returns
-    -------
-    FileResponse
-        Streamed response with media type ``text/plain`` that prompts the browser to download the file.
-
-    Raises
-    ------
-    HTTPException
-        **404** - digest directory is missing or contains no ``*.txt`` file.
-        **403** - the process lacks permission to read the directory or file.
-
-    """
-    directory = TMP_BASE_PATH / digest_id
-
-    if not directory.is_dir():
-        raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail=f"Digest {digest_id!r} not found")
-
-    try:
-        first_txt_file = next(directory.glob("*.txt"))
-    except StopIteration as exc:
-        raise HTTPException(
-            status_code=HTTP_404_NOT_FOUND,
-            detail=f"No .txt file found for digest {digest_id!r}",
-        ) from exc
-
-    try:
-        return FileResponse(path=first_txt_file, media_type="text/plain", filename=first_txt_file.name)
-    except PermissionError as exc:
-        raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail=f"Permission denied for {first_txt_file}") from exc
diff --git a/src/server/routers/ingest.py b/src/server/routers/ingest.py
index f528ba69..117161bf 100644
--- a/src/server/routers/ingest.py
+++ b/src/server/routers/ingest.py
@@ -1,109 +1,125 @@
 """Ingest endpoint for the API."""
 
-from fastapi import APIRouter, Request, status
-from fastapi.responses import JSONResponse
+from fastapi import APIRouter, HTTPException, Request, status
+from fastapi.responses import FileResponse, JSONResponse
 
-from server.form_types import IntForm, OptStrForm, StrForm
-from server.models import IngestErrorResponse, IngestRequest, IngestSuccessResponse, PatternType
-from server.query_processor import process_query
+from gitingest.config import TMP_BASE_PATH
+from server.models import IngestRequest
+from server.routers_utils import COMMON_INGEST_RESPONSES, _perform_ingestion
+from server.server_config import MAX_DISPLAY_SIZE
 from server.server_utils import limiter
 
 router = APIRouter()
 
 
-@router.post(
-    "/api/ingest",
-    responses={
-        status.HTTP_200_OK: {"model": IngestSuccessResponse, "description": "Successful ingestion"},
-        status.HTTP_400_BAD_REQUEST: {"model": IngestErrorResponse, "description": "Bad request or processing error"},
-        status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": IngestErrorResponse, "description": "Internal server error"},
-    },
-)
+@router.post("/api/ingest", responses=COMMON_INGEST_RESPONSES)
 @limiter.limit("10/minute")
 async def api_ingest(
-    request: Request,  # noqa: ARG001 (unused) pylint: disable=unused-argument
-    input_text: StrForm,
-    max_file_size: IntForm,
-    pattern_type: StrForm = "exclude",
-    pattern: StrForm = "",
-    token: OptStrForm = None,
+    request: Request,  # noqa: ARG001 (unused-function-argument) # pylint: disable=unused-argument
+    ingest_request: IngestRequest,
 ) -> JSONResponse:
     """Ingest a Git repository and return processed content.
 
-    This endpoint processes a Git repository by cloning it, analyzing its structure,
+    **This endpoint processes a Git repository by cloning it, analyzing its structure,**
     and returning a summary with the repository's content. The response includes
     file tree structure, processed content, and metadata about the ingestion.
 
-    Parameters
-    ----------
-    request : Request
-        FastAPI request object
-    input_text : StrForm
-        Git repository URL or slug to ingest
-    max_file_size : IntForm
-        Maximum file size slider position (0-500) for filtering files
-    pattern_type : StrForm
-        Type of pattern to use for file filtering ("include" or "exclude")
-    pattern : StrForm
-        Glob/regex pattern string for file filtering
-    token : OptStrForm
-        GitHub personal access token (PAT) for accessing private repositories
-
-    Returns
-    -------
-    JSONResponse
-        Success response with ingestion results or error response with appropriate HTTP status code
+    **Parameters**
 
+    - **ingest_request** (`IngestRequest`): Pydantic model containing ingestion parameters
+
+    **Returns**
+
+    - **JSONResponse**: Success response with ingestion results or error response with appropriate HTTP status code
+
+    """
+    return await _perform_ingestion(
+        input_text=ingest_request.input_text,
+        max_file_size=ingest_request.max_file_size,
+        pattern_type=ingest_request.pattern_type,
+        pattern=ingest_request.pattern,
+        token=ingest_request.token,
+    )
+
+
+@router.get("/api/{user}/{repository}", responses=COMMON_INGEST_RESPONSES)
+@limiter.limit("10/minute")
+async def api_ingest_get(
+    request: Request,  # noqa: ARG001 (unused-function-argument) # pylint: disable=unused-argument
+    user: str,
+    repository: str,
+    max_file_size: int = MAX_DISPLAY_SIZE,
+    pattern_type: str = "exclude",
+    pattern: str = "",
+    token: str = "",
+) -> JSONResponse:
+    """Ingest a GitHub repository via GET and return processed content.
+
+    **This endpoint processes a GitHub repository by analyzing its structure and returning a summary**
+    with the repository's content. The response includes file tree structure, processed content, and
+    metadata about the ingestion. All ingestion parameters are optional and can be provided as query parameters.
+
+    **Path Parameters**
+    - **user** (`str`): GitHub username or organization
+    - **repository** (`str`): GitHub repository name
+
+    **Query Parameters**
+    - **max_file_size** (`int`, optional): Maximum file size to include in the digest (default: 50 KB)
+    - **pattern_type** (`str`, optional): Type of pattern to use ("include" or "exclude", default: "exclude")
+    - **pattern** (`str`, optional): Pattern to include or exclude in the query (default: "")
+    - **token** (`str`, optional): GitHub personal access token for private repositories (default: "")
+
+    **Returns**
+    - **JSONResponse**: Success response with ingestion results or error response with appropriate HTTP status code
     """
+    return await _perform_ingestion(
+        input_text=f"{user}/{repository}",
+        max_file_size=max_file_size,
+        pattern_type=pattern_type,
+        pattern=pattern,
+        token=token or None,
+    )
+
+
+@router.get("/api/download/file/{ingest_id}", response_class=FileResponse)
+async def download_ingest(ingest_id: str) -> FileResponse:
+    """Download the first text file produced for an ingest ID.
+
+    **This endpoint retrieves the first ``*.txt`` file produced during the ingestion process**
+    and returns it as a downloadable file. The file is streamed with media type ``text/plain``
+    and prompts the browser to download it.
+
+    **Parameters**
+
+    - **ingest_id** (`str`): Identifier that the ingest step emitted
+
+    **Returns**
+
+    - **FileResponse**: Streamed response with media type ``text/plain``
+
+    **Raises**
+
+    - **HTTPException**: **404** - digest directory is missing or contains no ``*.txt`` file
+    - **HTTPException**: **403** - the process lacks permission to read the directory or file
+
+    """
+    directory = TMP_BASE_PATH / ingest_id
+
+    if not directory.is_dir():
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Digest {ingest_id!r} not found")
+
+    try:
+        first_txt_file = next(directory.glob("*.txt"))
+    except StopIteration as exc:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail=f"No .txt file found for digest {ingest_id!r}",
+        ) from exc
+
     try:
-        # Validate input using Pydantic model
-        ingest_request = IngestRequest(
-            input_text=input_text,
-            max_file_size=max_file_size,
-            pattern_type=PatternType(pattern_type),
-            pattern=pattern,
-            token=token,
-        )
-
-        result = await process_query(
-            input_text=ingest_request.input_text,
-            slider_position=ingest_request.max_file_size,
-            pattern_type=ingest_request.pattern_type,
-            pattern=ingest_request.pattern,
-            token=ingest_request.token,
-        )
-
-        if isinstance(result, IngestErrorResponse):
-            # Return structured error response with 400 status code
-            return JSONResponse(
-                status_code=status.HTTP_400_BAD_REQUEST,
-                content=result.model_dump(),
-            )
-
-        # Return structured success response with 200 status code
-        return JSONResponse(
-            status_code=status.HTTP_200_OK,
-            content=result.model_dump(),
-        )
-
-    except ValueError as ve:
-        # Handle validation errors with 400 status code
-        error_response = IngestErrorResponse(
-            error=f"Validation error: {ve!s}",
-            repo_url=input_text,
-        )
-        return JSONResponse(
-            status_code=status.HTTP_400_BAD_REQUEST,
-            content=error_response.model_dump(),
-        )
-
-    except Exception as exc:
-        # Handle unexpected errors with 500 status code
-        error_response = IngestErrorResponse(
-            error=f"Internal server error: {exc!s}",
-            repo_url=input_text,
-        )
-        return JSONResponse(
-            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-            content=error_response.model_dump(),
-        )
+        return FileResponse(path=first_txt_file, media_type="text/plain", filename=first_txt_file.name)
+    except PermissionError as exc:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail=f"Permission denied for {first_txt_file}",
+        ) from exc
diff --git a/src/server/routers_utils.py b/src/server/routers_utils.py
new file mode 100644
index 00000000..358596fb
--- /dev/null
+++ b/src/server/routers_utils.py
@@ -0,0 +1,55 @@
+"""Utility functions for the ingest endpoints."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import status
+from fastapi.responses import JSONResponse
+
+from server.models import IngestErrorResponse, IngestSuccessResponse
+from server.query_processor import process_query
+
+COMMON_INGEST_RESPONSES: dict[int | str, dict[str, Any]] = {
+    status.HTTP_200_OK: {"model": IngestSuccessResponse, "description": "Successful ingestion"},
+    status.HTTP_400_BAD_REQUEST: {"model": IngestErrorResponse, "description": "Bad request or processing error"},
+    status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": IngestErrorResponse, "description": "Internal server error"},
+}
+
+
+async def _perform_ingestion(
+    input_text: str,
+    max_file_size: int,
+    pattern_type: str,
+    pattern: str,
+    token: str | None,
+) -> JSONResponse:
+    """Run ``process_query`` and wrap the result in a ``FastAPI`` ``JSONResponse``.
+
+    Consolidates error handling shared by the ``POST`` and ``GET`` ingest endpoints.
+    """
+    try:
+        result = await process_query(
+            input_text=input_text,
+            slider_position=max_file_size,
+            pattern_type=pattern_type,
+            pattern=pattern,
+            token=token,
+        )
+
+        if isinstance(result, IngestErrorResponse):
+            # Return structured error response with 400 status code
+            return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content=result.model_dump())
+
+        # Return structured success response with 200 status code
+        return JSONResponse(status_code=status.HTTP_200_OK, content=result.model_dump())
+
+    except ValueError as ve:
+        # Handle validation errors with 400 status code
+        error_response = IngestErrorResponse(error=f"Validation error: {ve!s}")
+        return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content=error_response.model_dump())
+
+    except Exception as exc:
+        # Handle unexpected errors with 500 status code
+        error_response = IngestErrorResponse(error=f"Internal server error: {exc!s}")
+        return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content=error_response.model_dump())
diff --git a/src/server/templates/components/result.jinja b/src/server/templates/components/result.jinja
index a5a0a803..debf132f 100644
--- a/src/server/templates/components/result.jinja
+++ b/src/server/templates/components/result.jinja
@@ -1,1 +1,107 @@
-<div class="mt-10" data-results></div>
+<div class="mt-10">
+    <!-- Error Message (hidden by default) -->
+    <div id="results-error" style="display:none"></div>
+    <!-- Loading Spinner (hidden by default) -->
+    <div id="results-loading" style="display:none">
+        <div class="relative mt-10">
+            <div class="w-full h-full absolute inset-0 bg-black rounded-xl translate-y-2 translate-x-2"></div>
+            <div class="bg-[#fafafa] rounded-xl border-[3px] border-gray-900 p-6 relative z-20 flex flex-col items-center space-y-4">
+                <div class="loader border-8 border-[#fff4da] border-t-8 border-t-[#ffc480] rounded-full w-16 h-16 animate-spin"></div>
+                <p class="text-lg font-bold text-gray-900">Loading...</p>
+            </div>
+        </div>
+    </div>
+    <!-- Results Section (hidden by default) -->
+    <div id="results-section" style="display:none">
+        <div class="relative">
+            <div class="w-full h-full absolute inset-0 bg-gray-900 rounded-xl translate-y-2 translate-x-2"></div>
+            <div class="bg-[#fafafa] rounded-xl border-[3px] border-gray-900 p-6 relative z-20 space-y-6">
+                <div class="grid grid-cols-1 md:grid-cols-12 gap-6">
+                    <div class="md:col-span-5">
+                        <div class="flex justify-between items-center mb-4 py-2">
+                            <h3 class="text-lg font-bold text-gray-900">Summary</h3>
+                        </div>
+                        <div class="relative">
+                            <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0"></div>
+                            <textarea id="result-summary"
+                                      class="w-full h-[160px] p-4 bg-[#fff4da] border-[3px] border-gray-900 rounded font-mono text-sm resize-none focus:outline-none relative z-10"
+                                      readonly></textarea>
+                        </div>
+                        <div class="relative mt-4 inline-block group ml-4">
+                            <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0"></div>
+                            <button onclick="copyFullDigest()"
+                                    class="inline-flex items-center px-4 py-2 bg-[#ffc480] border-[3px] border-gray-900 text-gray-900 rounded group-hover:-translate-y-px group-hover:-translate-x-px transition-transform relative z-10">
+                                <svg class="w-4 h-4 mr-2"
+                                     fill="none"
+                                     stroke="currentColor"
+                                     viewBox="0 0 24 24">
+                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
+                                </svg>
+                                Copy all
+                            </button>
+                        </div>
+                        <div class="relative mt-4 inline-block group ml-4">
+                            <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0"></div>
+                            <button onclick="downloadFullDigest()"
+                                    class="inline-flex items-center px-4 py-2 bg-[#ffc480] border-[3px] border-gray-900 text-gray-900 rounded group-hover:-translate-y-px group-hover:-translate-x-px transition-transform relative z-10">
+                                <svg class="w-4 h-4 mr-2"
+                                     fill="none"
+                                     stroke="currentColor"
+                                     viewBox="0 0 24 24">
+                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
+                                </svg>
+                                Download
+                            </button>
+                        </div>
+                    </div>
+                    <div class="md:col-span-7">
+                        <div class="flex justify-between items-center mb-4">
+                            <h3 class="text-lg font-bold text-gray-900">Directory Structure</h3>
+                            <div class="relative group">
+                                <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0"></div>
+                                <button onclick="copyText('directory-structure')"
+                                        class="px-4 py-2 bg-[#ffc480] border-[3px] border-gray-900 text-gray-900 rounded group-hover:-translate-y-px group-hover:-translate-x-px transition-transform relative z-10 flex items-center gap-2">
+                                    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
+                                    </svg>
+                                    Copy
+                                </button>
+                            </div>
+                        </div>
+                        <div class="relative">
+                            <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0"></div>
+                            <div class="directory-structure w-full p-4 bg-[#fff4da] border-[3px] border-gray-900 rounded font-mono text-sm resize-y focus:outline-none relative z-10 h-[215px] overflow-auto"
+                                 id="directory-structure-container"
+                                 readonly>
+                                <input type="hidden" id="directory-structure-content" value="" />
+                                <pre id="directory-structure-pre"></pre>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+                <div>
+                    <div class="flex justify-between items-center mb-4">
+                        <h3 class="text-lg font-bold text-gray-900">Files Content</h3>
+                        <div class="relative group">
+                            <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0"></div>
+                            <button onclick="copyText('result-text')"
+                                    class="px-4 py-2 bg-[#ffc480] border-[3px] border-gray-900 text-gray-900 rounded group-hover:-translate-y-px group-hover:-translate-x-px transition-transform relative z-10 flex items-center gap-2">
+                                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
+                                </svg>
+                                Copy
+                            </button>
+                        </div>
+                    </div>
+                    <div class="relative">
+                        <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0"></div>
+                        <textarea id="result-content"
+                                  class="result-text w-full p-4 bg-[#fff4da] border-[3px] border-gray-900 rounded font-mono text-sm resize-y focus:outline-none relative z-10"
+                                  style="min-height: 600px"
+                                  readonly></textarea>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
diff --git a/src/server/templates/swagger_ui.jinja b/src/server/templates/swagger_ui.jinja
new file mode 100644
index 00000000..02185a9e
--- /dev/null
+++ b/src/server/templates/swagger_ui.jinja
@@ -0,0 +1,38 @@
+{% extends "base.jinja" %}
+{% block title %}GitIngest API{% endblock %}
+{% block content %}
+    <div class="mb-8">
+        <div class="relative w-full flex sm:flex-row flex-col justify-center sm:items-center">
+            {# Title & Sparkles #}
+            <h1 class="landing-page-title">
+                GitIngest
+                <br>
+                API&nbsp;
+            </h1>
+            <img src="/static/svg/sparkle-red.svg" class="sparkle-red no-drag">
+            <img src="/static/svg/sparkle-green.svg" class="sparkle-green no-drag">
+        </div>
+        <p class="intro-text mt-8">Turn any Git repository into a simple text digest of its codebase.</p>
+        <p class="intro-text mt-0">This is useful for feeding a codebase into any LLM.</p>
+    </div>
+    <div class="bg-[#fff4da] rounded-xl border-[3px] border-gray-900 p-4 md:p-8 relative z-20">
+        <div id="swagger-ui"></div>
+    </div>
+    <link rel="stylesheet"
+          href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
+    <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
+    <script>
+  window.onload = function() {
+    SwaggerUIBundle({
+      url: "/openapi.json",
+      dom_id: '#swagger-ui',
+      presets: [
+        SwaggerUIBundle.presets.apis,
+        SwaggerUIBundle.SwaggerUIStandalonePreset
+      ],
+      layout: "BaseLayout",
+      deepLinking: true,
+    });
+  }
+    </script>
+{% endblock %}
diff --git a/src/static/js/git.js b/src/static/js/git.js
index 8879063b..8a8c83c8 100644
--- a/src/static/js/git.js
+++ b/src/static/js/git.js
@@ -27,7 +27,7 @@ document.addEventListener('DOMContentLoaded', () => {
                 value: form,
                 enumerable: true
             });
-            handleSubmit(submitEvent, false);
+            handleSubmit(submitEvent, true);
         });
     }
 });
diff --git a/src/static/js/git_form.js b/src/static/js/git_form.js
index 217b4b6b..a90814d4 100644
--- a/src/static/js/git_form.js
+++ b/src/static/js/git_form.js
@@ -1,21 +1,19 @@
 // Strike-through / un-strike file lines when the pattern-type menu flips.
 function changePattern() {
-    const files = document.getElementsByName('tree-line');
-
-    files.forEach((el) => {
-        if (el.textContent.includes('Directory structure:')) {return;}
-        [
-            'line-through',
-            'text-gray-500',
-            'hover:text-inherit',
-            'hover:no-underline',
-            'hover:line-through',
-            'hover:text-gray-500',
-        ].forEach((cls) => el.classList.toggle(cls));
+    const dirPre = document.getElementById('directory-structure-pre');
+
+    if (!dirPre) {return;}
+
+    const treeLineElements = Array.from(dirPre.querySelectorAll('pre[name="tree-line"]'));
+
+    // Skip the first tree line element
+    treeLineElements.slice(2).forEach((element) => {
+        element.classList.toggle('line-through');
+        element.classList.toggle('text-gray-500');
     });
 }
 
-// Show/hide the Personal-Access-Token section when the “Private repository” checkbox is toggled.
+// Show/hide the Personal-Access-Token section when the "Private repository" checkbox is toggled.
 function toggleAccessSettings() {
     const container = document.getElementById('accessSettingsContainer');
     const examples = document.getElementById('exampleRepositories');
@@ -28,15 +26,6 @@ function toggleAccessSettings() {
 
 
 document.addEventListener('DOMContentLoaded', () => {
-    document
-        .getElementById('pattern_type')
-        ?.addEventListener('change', () => changePattern());
-
-    document
-        .getElementById('showAccessSettings')
-        ?.addEventListener('change', toggleAccessSettings);
-
-    // Initial UI sync
     toggleAccessSettings();
     changePattern();
 });
diff --git a/src/static/js/utils.js b/src/static/js/utils.js
index 393fe12d..b20222be 100644
--- a/src/static/js/utils.js
+++ b/src/static/js/utils.js
@@ -1,3 +1,59 @@
+function getFileName(element) {
+    const indentSize = 4;
+    let path = '';
+    let prevIndentLevel = null;
+
+    while (element) {
+        const line = element.textContent;
+        const index = line.search(/[a-zA-Z0-9_.-]/);
+        const indentLevel = index / indentSize;
+
+        // Stop when we reach or go above the top-level directory
+        if (indentLevel <= 1) {
+            break;
+        }
+
+        // Only include directories that are one level above the previous
+        if (prevIndentLevel === null || indentLevel === prevIndentLevel - 1) {
+            const fileName = line.substring(index).trim();
+
+            path = fileName + path;
+            prevIndentLevel = indentLevel;
+        }
+
+        element = element.previousElementSibling;
+    }
+
+    return path;
+}
+
+function toggleFile(element) {
+    const patternInput = document.getElementById('pattern');
+    const patternFiles = patternInput.value ? patternInput.value.split(',').map((item) => item.trim()) : [];
+
+    const directoryContainer = document.getElementById('directory-structure-container');
+    const treeLineElements = Array.from(directoryContainer.children).filter((child) => child.tagName === 'PRE');
+
+    // Skip the first two tree lines (header and repository name)
+    if (treeLineElements[0] === element || treeLineElements[1] === element) {
+        return;
+    }
+
+    element.classList.toggle('line-through');
+    element.classList.toggle('text-gray-500');
+
+    const fileName = getFileName(element);
+    const fileIndex = patternFiles.indexOf(fileName);
+
+    if (fileIndex !== -1) {
+        patternFiles.splice(fileIndex, 1);
+    } else {
+        patternFiles.push(fileName);
+    }
+
+    patternInput.value = patternFiles.join(', ');
+}
+
 // Copy functionality
 function copyText(className) {
     let textToCopy;
@@ -45,249 +101,148 @@ function copyText(className) {
         });
 }
 
-function getFileName(element) {
-    const indentSize = 4;
-    let path = '';
-    let prevIndentLevel = null;
+// Helper functions for toggling result blocks
+function showLoading() {
+    document.getElementById('results-loading').style.display = 'block';
+    document.getElementById('results-section').style.display = 'none';
+    document.getElementById('results-error').style.display = 'none';
+}
+function showResults() {
+    document.getElementById('results-loading').style.display = 'none';
+    document.getElementById('results-section').style.display = 'block';
+    document.getElementById('results-error').style.display = 'none';
+}
+function showError(msg) {
+    document.getElementById('results-loading').style.display = 'none';
+    document.getElementById('results-section').style.display = 'none';
+    const errorDiv = document.getElementById('results-error');
 
-    while (element) {
-        const line = element.textContent;
-        const index = line.search(/[a-zA-Z0-9_.-]/);
-        const indentLevel = index / indentSize;
+    errorDiv.innerHTML = msg;
+    errorDiv.style.display = 'block';
+}
 
-        // Stop when we reach or go above the top-level directory
-        if (indentLevel <= 1) {
-            break;
-        }
-        if (prevIndentLevel === null || indentLevel === prevIndentLevel - 1) {
-            const fileName = line.substring(index).trim();
+// Helper function to collect form data
+function collectFormData(form) {
+    const json_data = {};
+    const inputText = form.querySelector('[name="input_text"]');
+    const token = form.querySelector('[name="token"]');
+    const slider = document.getElementById('file_size');
+    const patternType = document.getElementById('pattern_type');
+    const pattern = document.getElementById('pattern');
 
-            path = fileName + path;
-            prevIndentLevel = indentLevel;
-        }
-        element = element.previousElementSibling;
-    }
+    if (inputText) {json_data.input_text = inputText.value;}
+    if (token) {json_data.token = token.value;}
+    if (slider) {json_data.max_file_size = slider.value;}
+    if (patternType) {json_data.pattern_type = patternType.value;}
+    if (pattern) {json_data.pattern = pattern.value;}
 
-    return path;
+    return json_data;
 }
 
-function toggleFile(element) {
-    const patternInput = document.getElementById('pattern');
-    const patternFiles = patternInput.value
-        ? patternInput.value.split(',').map((item) => item.trim())
-        : [];
-
-    const directoryContainer = document.getElementById('directory-structure-container');
-    const treeLineElements = Array.from(directoryContainer.children).filter(
-        (child) => child.tagName === 'PRE',
-    );
+// Helper function to manage button loading state
+function setButtonLoadingState(submitButton, isLoading) {
+    if (!isLoading) {
+        submitButton.disabled = false;
+        submitButton.innerHTML = submitButton.getAttribute('data-original-content') || 'Submit';
+        submitButton.classList.remove('bg-[#ffb14d]');
 
-    // Skip header and repository name
-    if (treeLineElements.slice(0, 2).includes(element)) {
         return;
     }
 
-    element.classList.toggle('line-through');
-    element.classList.toggle('text-gray-500');
+    // Store original content if not already stored
+    if (!submitButton.getAttribute('data-original-content')) {
+        submitButton.setAttribute('data-original-content', submitButton.innerHTML);
+    }
 
-    const fileName = getFileName(element);
-    const idx = patternFiles.indexOf(fileName);
+    submitButton.disabled = true;
+    submitButton.innerHTML = `
+        <div class="flex items-center justify-center">
+            <svg class="animate-spin h-5 w-5 text-gray-900" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
+                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+            </svg>
+            <span class="ml-2">Processing...</span>
+        </div>
+    `;
+    submitButton.classList.add('bg-[#ffb14d]');
+}
 
-    if (idx !== -1) {
-        patternFiles.splice(idx, 1);
-    } else {
-        patternFiles.push(fileName);
+// Helper function to handle successful response
+function handleSuccessfulResponse(data) {
+    // Show results section
+    showResults();
+
+    // Store the ingest_id for download functionality
+    window.currentIngestId = data.ingest_id;
+
+    // Set plain text content for summary, tree, and content
+    document.getElementById('result-summary').value = data.summary || '';
+    document.getElementById('directory-structure-content').value = data.tree || '';
+    document.getElementById('result-content').value = data.content || '';
+
+    // Populate directory structure lines as clickable <pre> elements
+    const dirPre = document.getElementById('directory-structure-pre');
+
+    if (dirPre && data.tree) {
+        dirPre.innerHTML = '';
+        data.tree.split('\n').forEach((line) => {
+            const pre = document.createElement('pre');
+
+            pre.setAttribute('name', 'tree-line');
+            pre.className = 'cursor-pointer hover:line-through hover:text-gray-500';
+            pre.textContent = line;
+            pre.onclick = function () { toggleFile(this); };
+            dirPre.appendChild(pre);
+        });
     }
 
-    patternInput.value = patternFiles.join(', ');
+    // Scroll to results
+    document.getElementById('results-section').scrollIntoView({ behavior: 'smooth', block: 'start' });
 }
 
-function handleSubmit(event, showLoading = false) {
+function handleSubmit(event, showLoadingSpinner = false) {
     event.preventDefault();
     const form = event.target || document.getElementById('ingestForm');
 
     if (!form) {return;}
 
-    // Declare resultsSection before use
-    const resultsSection = document.querySelector('[data-results]');
-
-    if (resultsSection) {
-    // Show in-content loading spinner
-        resultsSection.innerHTML = `
-            <div class="relative mt-10">
-                <div class="w-full h-full absolute inset-0 bg-black rounded-xl translate-y-2 translate-x-2"></div>
-                <div class="bg-[#fafafa] rounded-xl border-[3px] border-gray-900 p-6 relative z-20 flex flex-col items-center space-y-4">
-                    <div class="loader border-8 border-[#fff4da] border-t-8 border-t-[#ffc480] rounded-full w-16 h-16 animate-spin"></div>
-                    <p class="text-lg font-bold text-gray-900">Loading...</p>
-                </div>
-            </div>
-        `;
+    if (showLoadingSpinner) {
+        showLoading();
     }
 
     const submitButton = form.querySelector('button[type="submit"]');
 
     if (!submitButton) {return;}
 
-    const formData = new FormData(form);
-
-    // Update file size
-    const slider = document.getElementById('file_size');
-
-    if (slider) {
-        formData.delete('max_file_size');
-        formData.append('max_file_size', slider.value);
-    }
-
-    // Update pattern type and pattern
-    const patternType = document.getElementById('pattern_type');
-    const pattern = document.getElementById('pattern');
-
-    if (patternType && pattern) {
-        formData.delete('pattern_type');
-        formData.delete('pattern');
-        formData.append('pattern_type', patternType.value);
-        formData.append('pattern', pattern.value);
-    }
+    const json_data = collectFormData(form);
 
-    const originalContent = submitButton.innerHTML;
-
-    if (showLoading) {
-        submitButton.disabled = true;
-        submitButton.innerHTML = `
-            <div class="flex items-center justify-center">
-                <svg class="animate-spin h-5 w-5 text-gray-900" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
-                    <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
-                    <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
-                </svg>
-                <span class="ml-2">Processing...</span>
-            </div>
-        `;
-        submitButton.classList.add('bg-[#ffb14d]');
+    if (showLoadingSpinner) {
+        setButtonLoadingState(submitButton, true);
     }
 
-    // Submit the form to /api/ingest
-    fetch('/api/ingest', { method: 'POST', body: formData })
+    // Submit the form to /api/ingest as JSON
+    fetch('/api/ingest', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(json_data)
+    })
         .then((response) => response.json())
-        .then((data) => {
+        .then( (data) => {
             // Hide loading overlay
-            if (resultsSection) {resultsSection.innerHTML = '';}
-            submitButton.disabled = false;
-            submitButton.innerHTML = originalContent;
-
-            if (!resultsSection) {return;}
+            setButtonLoadingState(submitButton, false);
 
             // Handle error
             if (data.error) {
-                resultsSection.innerHTML = `<div class='mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700'>${data.error}</div>`;
+                showError(`<div class='mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700'>${data.error}</div>`);
 
                 return;
             }
 
-            // Build the static HTML structure
-            resultsSection.innerHTML = `
-                <div class="relative">
-                    <div class="w-full h-full absolute inset-0 bg-gray-900 rounded-xl translate-y-2 translate-x-2"></div>
-                    <div class="bg-[#fafafa] rounded-xl border-[3px] border-gray-900 p-6 relative z-20 space-y-6">
-                        <div class="grid grid-cols-1 md:grid-cols-12 gap-6">
-                            <div class="md:col-span-5">
-                                <div class="flex justify-between items-center mb-4 py-2">
-                                    <h3 class="text-lg font-bold text-gray-900">Summary</h3>
-                                </div>
-                                <div class="relative">
-                                    <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0"></div>
-                                    <textarea id="result-summary" class="w-full h-[160px] p-4 bg-[#fff4da] border-[3px] border-gray-900 rounded font-mono text-sm resize-none focus:outline-none relative z-10" readonly></textarea>
-                                </div>
-                                <div class="relative mt-4 inline-block group ml-4">
-                                    <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0"></div>
-                                    <button onclick="copyFullDigest()" class="inline-flex items-center px-4 py-2 bg-[#ffc480] border-[3px] border-gray-900 text-gray-900 rounded group-hover:-translate-y-px group-hover:-translate-x-px transition-transform relative z-10">
-                                        <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /></svg>
-                                        Copy all
-                                    </button>
-                                </div>
-                                <div class="relative mt-4 inline-block group ml-4">
-                                    <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0"></div>
-                                    <button onclick="downloadFullDigest()" class="inline-flex items-center px-4 py-2 bg-[#ffc480] border-[3px] border-gray-900 text-gray-900 rounded group-hover:-translate-y-px group-hover:-translate-x-px transition-transform relative z-10">
-                                        <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
-                                        Download
-                                    </button>
-                                </div>
-                            </div>
-                            <div class="md:col-span-7">
-                                <div class="flex justify-between items-center mb-4">
-                                    <h3 class="text-lg font-bold text-gray-900">Directory Structure</h3>
-                                    <div class="relative group">
-                                        <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0"></div>
-                                        <button onclick="copyText('directory-structure')" class="px-4 py-2 bg-[#ffc480] border-[3px] border-gray-900 text-gray-900 rounded group-hover:-translate-y-px group-hover:-translate-x-px transition-transform relative z-10 flex items-center gap-2">
-                                            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /></svg>
-                                            Copy
-                                        </button>
-                                    </div>
-                                </div>
-                                <div class="relative">
-                                    <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0"></div>
-                                    <div class="directory-structure w-full p-4 bg-[#fff4da] border-[3px] border-gray-900 rounded font-mono text-sm resize-y focus:outline-none relative z-10 h-[215px] overflow-auto" id="directory-structure-container" readonly>
-                                        <input type="hidden" id="directory-structure-content" value="" />
-                                        <pre id="directory-structure-pre"></pre>
-                                    </div>
-                                </div>
-                            </div>
-                        </div>
-                        <div>
-                            <div class="flex justify-between items-center mb-4">
-                                <h3 class="text-lg font-bold text-gray-900">Files Content</h3>
-                                <div class="relative group">
-                                    <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0"></div>
-                                    <button onclick="copyText('result-text')" class="px-4 py-2 bg-[#ffc480] border-[3px] border-gray-900 text-gray-900 rounded group-hover:-translate-y-px group-hover:-translate-x-px transition-transform relative z-10 flex items-center gap-2">
-                                        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /></svg>
-                                        Copy
-                                    </button>
-                                </div>
-                            </div>
-                            <div class="relative">
-                                <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0"></div>
-                                <textarea id="result-content" class="result-text w-full p-4 bg-[#fff4da] border-[3px] border-gray-900 rounded font-mono text-sm resize-y focus:outline-none relative z-10" style="min-height: 600px" readonly></textarea>
-                            </div>
-                        </div>
-                    </div>
-                </div>
-            `;
-
-            // Set plain text content for summary, tree, and content
-            document.getElementById('result-summary').value = data.summary || '';
-            document.getElementById('directory-structure-content').value = data.tree || '';
-            document.getElementById('result-content').value = data.content || '';
-
-            // Populate directory structure lines as clickable <pre> elements
-            const dirPre = document.getElementById('directory-structure-pre');
-
-            if (dirPre && data.tree) {
-                dirPre.innerHTML = '';
-                data.tree.split('\n').forEach((line) => {
-                    const pre = document.createElement('pre');
-
-                    pre.setAttribute('name', 'tree-line');
-                    pre.className = 'cursor-pointer hover:line-through hover:text-gray-500';
-                    pre.textContent = line;
-                    pre.onclick = function () { toggleFile(this); };
-                    dirPre.appendChild(pre);
-                });
-            }
-
-            // Scroll to results
-            resultsSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
+            handleSuccessfulResponse(data);
         })
         .catch((error) => {
-            // Hide loading overlay
-            if (resultsSection) {
-                resultsSection.innerHTML = '';
-            }
-            submitButton.disabled = false;
-            submitButton.innerHTML = originalContent;
-            const errorContainer = document.querySelector('[data-results]');
-
-            if (errorContainer) {
-                errorContainer.innerHTML = `<div class='mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700'>${error}</div>`;
-            }
+            setButtonLoadingState(submitButton, false);
+            showError(`<div class='mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700'>${error}</div>`);
         });
 }
 
@@ -316,33 +271,36 @@ function copyFullDigest() {
 }
 
 function downloadFullDigest() {
-    const summary = document.getElementById('result-summary').value;
-    const directoryStructure = document.getElementById('directory-structure-content').value;
-    const filesContent = document.querySelector('.result-text').value;
+    // Check if we have an ingest_id
+    if (!window.currentIngestId) {
+        console.error('No ingest_id available for download');
+
+        return;
+    }
 
-    // Create the full content with all three sections
-    const fullContent = `${summary}\n${directoryStructure}\n${filesContent}`;
+    // Show feedback on the button
+    const button = document.querySelector('[onclick="downloadFullDigest()"]');
+    const originalText = button.innerHTML;
 
-    // Create a blob with the content
-    const blob = new Blob([fullContent], { type: 'text/plain' });
+    button.innerHTML = `
+        <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
+        </svg>
+        Downloading...
+    `;
 
-    // Create a download link
-    const url = window.URL.createObjectURL(blob);
+    // Create a download link to the server endpoint
     const a = document.createElement('a');
 
-    a.href = url;
+    a.href = `/api/download/file/${window.currentIngestId}`;
     a.download = 'digest.txt';
     document.body.appendChild(a);
     a.click();
 
     // Clean up
-    window.URL.revokeObjectURL(url);
     document.body.removeChild(a);
 
-    // Show feedback on the button
-    const button = document.querySelector('[onclick="downloadFullDigest()"]');
-    const originalText = button.innerHTML;
-
+    // Update button to show success
     button.innerHTML = `
         <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
             <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>


Test patch:
diff --git a/tests/test_flow_integration.py b/tests/test_flow_integration.py
index f9428117..2129c0d9 100644
--- a/tests/test_flow_integration.py
+++ b/tests/test_flow_integration.py
@@ -56,7 +56,7 @@ async def test_remote_repository_analysis(request: pytest.FixtureRequest) -> Non
         "token": "",
     }
 
-    response = client.post("/api/ingest", data=form_data)
+    response = client.post("/api/ingest", json=form_data)
     assert response.status_code == status.HTTP_200_OK, f"Form submission failed: {response.text}"
 
     # Check that response is JSON
@@ -81,14 +81,13 @@ async def test_invalid_repository_url(request: pytest.FixtureRequest) -> None:
         "token": "",
     }
 
-    response = client.post("/api/ingest", data=form_data)
+    response = client.post("/api/ingest", json=form_data)
     # Should return 400 for invalid repository
     assert response.status_code == status.HTTP_400_BAD_REQUEST, f"Request failed: {response.text}"
 
     # Check that response is JSON error
     response_data = response.json()
     assert "error" in response_data
-    assert "repo_url" in response_data
 
 
 @pytest.mark.asyncio
@@ -104,7 +103,7 @@ async def test_large_repository(request: pytest.FixtureRequest) -> None:
         "token": "",
     }
 
-    response = client.post("/api/ingest", data=form_data)
+    response = client.post("/api/ingest", json=form_data)
     assert response.status_code == status.HTTP_200_OK, f"Request failed: {response.text}"
 
     response_data = response.json()
@@ -128,7 +127,7 @@ def make_request() -> None:
             "pattern": "",
             "token": "",
         }
-        response = client.post("/api/ingest", data=form_data)
+        response = client.post("/api/ingest", json=form_data)
         assert response.status_code == status.HTTP_200_OK, f"Request failed: {response.text}"
 
         response_data = response.json()
@@ -156,7 +155,7 @@ async def test_large_file_handling(request: pytest.FixtureRequest) -> None:
         "token": "",
     }
 
-    response = client.post("/api/ingest", data=form_data)
+    response = client.post("/api/ingest", json=form_data)
     assert response.status_code == status.HTTP_200_OK, f"Request failed: {response.text}"
 
     response_data = response.json()
@@ -179,7 +178,7 @@ async def test_repository_with_patterns(request: pytest.FixtureRequest) -> None:
         "token": "",
     }
 
-    response = client.post("/api/ingest", data=form_data)
+    response = client.post("/api/ingest", json=form_data)
     assert response.status_code == status.HTTP_200_OK, f"Request failed: {response.text}"
 
     response_data = response.json()