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, 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 { (self.offset > 0) .then(|| self.list_path(&self.state, self.offset.saturating_sub(PAGE_SIZE))) } fn next_path(&self) -> Option { 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 { 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 { 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 { 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 { 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 { 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 { let is_open_tab = page.state == "open"; let tabs = format!( r#""#, 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#"{}"#, 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#"
No items found.
"#); } 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#" {}{}"#, web::html_escape(src), web::html_escape(tgt) ), _ => String::new(), } } else { String::new() }; items_html.push_str(&format!( r#"
{icon}
{title}{branch_info}
#{num} opened {time} by {author}
"#, 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#"← Newer"#, web::html_escape(&previous_path) )); } if let Some(next_path) = page.next_path() { pagination.push_str(&format!( r#"Older →"#, web::html_escape(&next_path) )); } if !pagination.is_empty() { pagination = format!(r#""#, pagination); } let content = format!( r#"

{kind_label}

{new_btn}
{tabs}
{items_html}
{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 }