branch: main
list.rs
13017 bytesRaw
use crate::{api, issues, presentation, web};
use worker::*;

use super::Url;
use presentation::{Action, Hint, NegotiatedRepresentation};

const PAGE_SIZE: usize = 25;

struct ListPage {
    owner: String,
    repo_name: String,
    kind: String,
    kind_label: &'static str,
    kind_url: &'static str,
    new_label: &'static str,
    new_url: &'static str,
    default_branch: String,
    state: String,
    offset: usize,
    items: Vec<issues::IssueRow>,
    has_more: bool,
    open_count: i64,
    closed_count: i64,
}

impl ListPage {
    fn base_path(&self) -> String {
        format!("/{}/{}/{}", self.owner, self.repo_name, self.kind_url)
    }

    fn new_path(&self) -> String {
        format!("/{}/{}/{}", self.owner, self.repo_name, self.new_url)
    }

    fn item_path(&self, number: i64) -> String {
        format!(
            "/{}/{}/{}/{}",
            self.owner, self.repo_name, self.kind_url, number
        )
    }

    fn list_path(&self, state: &str, offset: usize) -> String {
        let mut path = self.base_path();
        let mut query = Vec::new();

        if state != "open" {
            query.push(format!("state={}", state));
        }
        if offset > 0 {
            query.push(format!("offset={}", offset));
        }

        if !query.is_empty() {
            path.push('?');
            path.push_str(&query.join("&"));
        }

        path
    }

    fn current_path(&self) -> String {
        self.list_path(&self.state, self.offset)
    }

    fn open_path(&self) -> String {
        self.list_path("open", 0)
    }

    fn closed_path(&self) -> String {
        self.list_path("closed", 0)
    }

    fn previous_path(&self) -> Option<String> {
        (self.offset > 0)
            .then(|| self.list_path(&self.state, self.offset.saturating_sub(PAGE_SIZE)))
    }

    fn next_path(&self) -> Option<String> {
        self.has_more
            .then(|| self.list_path(&self.state, self.offset + PAGE_SIZE))
    }

    fn item_label(&self) -> &'static str {
        if self.kind == "pr" {
            "Pull request"
        } else {
            "Issue"
        }
    }
}

fn build_list_page(
    sql: &SqlStorage,
    owner: &str,
    repo_name: &str,
    url: &Url,
    kind: &str,
) -> Result<ListPage> {
    let (default_branch, _) = web::resolve_default_branch(sql)?;
    let state = api::get_query(url, "state").unwrap_or_else(|| "open".to_string());
    let offset: usize = api::get_query(url, "offset")
        .and_then(|v| v.parse().ok())
        .unwrap_or(0);

    let items = issues::list_issues(sql, kind, &state, PAGE_SIZE + 1, offset)?;
    let has_more = items.len() > PAGE_SIZE;
    let items = items.into_iter().take(PAGE_SIZE).collect();

    let open_count = issues::count_issues(sql, kind, "open")?;
    let closed_count = issues::count_issues_not_open(sql, kind)?;

    let (kind_label, kind_url, new_label, new_url) = if kind == "pr" {
        ("Pull Requests", "pulls", "New pull request", "pulls/new")
    } else {
        ("Issues", "issues", "New issue", "issues/new")
    };

    Ok(ListPage {
        owner: owner.to_string(),
        repo_name: repo_name.to_string(),
        kind: kind.to_string(),
        kind_label,
        kind_url,
        new_label,
        new_url,
        default_branch,
        state,
        offset,
        items,
        has_more,
        open_count,
        closed_count,
    })
}

pub fn page_issues_list(
    sql: &SqlStorage,
    owner: &str,
    repo_name: &str,
    url: &Url,
    actor_name: Option<&str>,
) -> Result<Response> {
    let page = build_list_page(sql, owner, repo_name, url, "issue")?;
    render_list_html(&page, actor_name)
}

pub fn page_pulls_list(
    sql: &SqlStorage,
    owner: &str,
    repo_name: &str,
    url: &Url,
    actor_name: Option<&str>,
) -> Result<Response> {
    let page = build_list_page(sql, owner, repo_name, url, "pr")?;
    render_list_html(&page, actor_name)
}

pub fn page_issues_list_markdown(
    sql: &SqlStorage,
    owner: &str,
    repo_name: &str,
    url: &Url,
    actor_name: Option<&str>,
    selection: &NegotiatedRepresentation,
) -> Result<Response> {
    let page = build_list_page(sql, owner, repo_name, url, "issue")?;
    presentation::markdown_response(
        &render_list_markdown(&page, actor_name, selection),
        selection,
    )
}

pub fn page_pulls_list_markdown(
    sql: &SqlStorage,
    owner: &str,
    repo_name: &str,
    url: &Url,
    actor_name: Option<&str>,
    selection: &NegotiatedRepresentation,
) -> Result<Response> {
    let page = build_list_page(sql, owner, repo_name, url, "pr")?;
    presentation::markdown_response(
        &render_list_markdown(&page, actor_name, selection),
        selection,
    )
}

fn render_list_html(page: &ListPage, actor_name: Option<&str>) -> Result<Response> {
    let is_open_tab = page.state == "open";

    let tabs = format!(
        r#"<div class="issue-tabs">
          <a href="{open_href}" class="issue-tab{open_active}">{open_count} Open</a>
          <a href="{closed_href}" class="issue-tab{closed_active}">{closed_count} Closed</a>
        </div>"#,
        open_href = web::html_escape(&page.open_path()),
        open_active = if is_open_tab { " active" } else { "" },
        open_count = page.open_count,
        closed_href = web::html_escape(&page.closed_path()),
        closed_active = if !is_open_tab { " active" } else { "" },
        closed_count = page.closed_count,
    );

    let new_btn = if actor_name.is_some() {
        format!(
            r#"<a href="{}" class="btn-primary">{}</a>"#,
            web::html_escape(&page.new_path()),
            page.new_label
        )
    } else {
        String::new()
    };

    let mut items_html = String::new();
    if page.items.is_empty() {
        items_html.push_str(r#"<div class="issue-empty">No items found.</div>"#);
    } else {
        for item in &page.items {
            let state_class = match item.state.as_str() {
                "merged" => "merged",
                "closed" => "closed",
                _ => "open",
            };
            let state_icon = match item.state.as_str() {
                "merged" => "⟳",
                "closed" => "✓",
                _ => "●",
            };
            let branch_info = if page.kind == "pr" {
                match (&item.source_branch, &item.target_branch) {
                    (Some(src), Some(tgt)) => format!(
                        r#" <span class="pr-branch-pair"><code>{}</code> → <code>{}</code></span>"#,
                        web::html_escape(src),
                        web::html_escape(tgt)
                    ),
                    _ => String::new(),
                }
            } else {
                String::new()
            };
            items_html.push_str(&format!(
                r#"<div class="issue-item">
                  <span class="issue-state-icon {state_class}">{icon}</span>
                  <div class="issue-item-main">
                    <a href="{href}" class="issue-item-title">{title}</a>{branch_info}
                    <div class="issue-item-meta">
                      #{num} opened {time} by <strong>{author}</strong>
                    </div>
                  </div>
                </div>"#,
                state_class = state_class,
                icon = state_icon,
                href = web::html_escape(&page.item_path(item.number)),
                title = web::html_escape(&item.title),
                branch_info = branch_info,
                num = item.number,
                time = web::format_time(item.created_at),
                author = web::html_escape(&item.author_name),
            ));
        }
    }

    let mut pagination = String::new();
    if let Some(previous_path) = page.previous_path() {
        pagination.push_str(&format!(
            r#"<a href="{}" class="btn-action">← Newer</a>"#,
            web::html_escape(&previous_path)
        ));
    }
    if let Some(next_path) = page.next_path() {
        pagination.push_str(&format!(
            r#"<a href="{}" class="btn-action">Older →</a>"#,
            web::html_escape(&next_path)
        ));
    }
    if !pagination.is_empty() {
        pagination = format!(r#"<div class="pagination">{}</div>"#, pagination);
    }

    let content = format!(
        r#"<div class="issue-list-header">
          <h1>{kind_label}</h1>
          {new_btn}
        </div>
        {tabs}
        <div class="issue-list">{items_html}</div>
        {pagination}"#,
        kind_label = page.kind_label,
        new_btn = new_btn,
        tabs = tabs,
        items_html = items_html,
        pagination = pagination,
    );

    web::html_response(&web::layout(
        page.kind_label,
        &page.owner,
        &page.repo_name,
        &page.default_branch,
        actor_name,
        &content,
    ))
}

fn render_list_markdown(
    page: &ListPage,
    actor_name: Option<&str>,
    selection: &NegotiatedRepresentation,
) -> String {
    let mut markdown = format!(
        "# {}/{} {}\n\nCurrent state filter: `{}`\nCounts: `{}` open, `{}` not open\nPagination: offset=`{}`, page_size=`{}`\n",
        page.owner,
        page.repo_name,
        page.kind_label,
        page.state,
        page.open_count,
        page.closed_count,
        page.offset,
        PAGE_SIZE,
    );

    match (page.previous_path(), page.next_path()) {
        (Some(previous_path), Some(next_path)) => markdown.push_str(&format!(
            "Previous page: `{}`\nNext page: `{}`\n",
            previous_path, next_path
        )),
        (Some(previous_path), None) => {
            markdown.push_str(&format!(
                "Previous page: `{}`\nNext page: none\n",
                previous_path
            ));
        }
        (None, Some(next_path)) => {
            markdown.push_str(&format!(
                "Previous page: none\nNext page: `{}`\n",
                next_path
            ));
        }
        (None, None) => markdown.push_str("Previous page: none\nNext page: none\n"),
    }

    markdown.push_str("\n## Items\n");
    if page.items.is_empty() {
        markdown.push_str("No items found for this state filter.\n");
    } else {
        for item in &page.items {
            let mut line = format!(
                "- #{} - {} - {} - opened {} by {}",
                item.number,
                item.state,
                item.title,
                web::format_time(item.created_at),
                item.author_name,
            );
            if page.kind == "pr" {
                if let (Some(source_branch), Some(target_branch)) =
                    (&item.source_branch, &item.target_branch)
                {
                    line.push_str(&format!(
                        " - branches: `{}` -> `{}`",
                        source_branch, target_branch
                    ));
                }
            }
            markdown.push_str(&line);
            markdown.push('\n');
        }
    }

    markdown.push_str("\n## Item Paths (GET paths)\n");
    if page.items.is_empty() {
        markdown.push_str("No item paths on this page.\n");
    } else {
        for item in &page.items {
            markdown.push_str(&format!("- `{}`\n", page.item_path(item.number)));
        }
    }

    let mut actions = vec![
        Action::get(page.current_path(), "reload this list page"),
        Action::get(page.open_path(), "view open items"),
        Action::get(page.closed_path(), "view closed items"),
    ];

    if actor_name.is_some() {
        actions.push(Action::get(
            page.new_path(),
            format!("open the {} form", page.new_label),
        ));
    } else {
        actions.push(
            Action::get(page.new_path(), format!("open the {} form", page.new_label))
                .with_requires("authenticated user"),
        );
    }

    if let Some(previous_path) = page.previous_path() {
        actions.push(Action::get(
            previous_path,
            "view the previous page of results",
        ));
    }
    if let Some(next_path) = page.next_path() {
        actions.push(Action::get(next_path, "view the next page of results"));
    }

    let closed_hint = if page.kind == "pr" {
        "`closed` and `merged` pull requests both count as not open in the list summary."
    } else {
        "The closed count is reported with the existing not-open summary query."
    };

    let hints = vec![
        presentation::text_navigation_hint(*selection),
        Hint::new(format!(
            "Item detail pages live under `/{}/{}/{}/{{number}}`.",
            page.owner, page.repo_name, page.kind_url
        )),
        Hint::new(closed_hint),
        Hint::new(format!(
            "{} items are listed newest first, with {} results per page.",
            page.item_label(),
            PAGE_SIZE
        )),
    ];

    markdown.push_str(&presentation::render_actions_section(&actions));
    markdown.push_str(&presentation::render_hints_section(&hints));
    markdown
}