overhaul complete
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ref: refs/heads/main
|
||||
@@ -0,0 +1,8 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = true
|
||||
ignorecase = true
|
||||
precomposeunicode = true
|
||||
[http]
|
||||
receivepack = true
|
||||
@@ -0,0 +1 @@
|
||||
Unnamed repository; edit this file 'description' to name the repository.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to check the commit log message taken by
|
||||
# applypatch from an e-mail message.
|
||||
#
|
||||
# The hook should exit with non-zero status after issuing an
|
||||
# appropriate message if it wants to stop the commit. The hook is
|
||||
# allowed to edit the commit message file.
|
||||
#
|
||||
# To enable this hook, rename this file to "applypatch-msg".
|
||||
|
||||
. git-sh-setup
|
||||
commitmsg="$(git rev-parse --git-path hooks/commit-msg)"
|
||||
test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"}
|
||||
:
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to check the commit log message.
|
||||
# Called by "git commit" with one argument, the name of the file
|
||||
# that has the commit message. The hook should exit with non-zero
|
||||
# status after issuing an appropriate message if it wants to stop the
|
||||
# commit. The hook is allowed to edit the commit message file.
|
||||
#
|
||||
# To enable this hook, rename this file to "commit-msg".
|
||||
|
||||
# Uncomment the below to add a Signed-off-by line to the message.
|
||||
# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
|
||||
# hook is more suited to it.
|
||||
#
|
||||
# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
|
||||
# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
|
||||
|
||||
# This example catches duplicate Signed-off-by lines and messages that
|
||||
# would confuse 'git am'.
|
||||
|
||||
ret=0
|
||||
|
||||
test "" = "$(grep '^Signed-off-by: ' "$1" |
|
||||
sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || {
|
||||
echo >&2 Duplicate Signed-off-by lines.
|
||||
ret=1
|
||||
}
|
||||
|
||||
comment_re="$(
|
||||
{
|
||||
git config --get-regexp "^core\.comment(char|string)\$" ||
|
||||
echo '#'
|
||||
} | sed -n -e '
|
||||
${
|
||||
s/^[^ ]* //
|
||||
s|[][*./\]|\\&|g
|
||||
s/^auto$/[#;@!$%^&|:]/
|
||||
p
|
||||
}'
|
||||
)"
|
||||
scissors_line="^${comment_re} -\{8,\} >8 -\{8,\}\$"
|
||||
comment_line="^${comment_re}.*"
|
||||
blank_line='^[ ]*$'
|
||||
# Disallow lines starting with "diff -" or "Index: " in the body of the
|
||||
# message. Stop looking if we see a scissors line.
|
||||
line="$(sed -n -e "
|
||||
# Skip comments and blank lines at the start of the file.
|
||||
/${scissors_line}/q
|
||||
/${comment_line}/d
|
||||
/${blank_line}/d
|
||||
# The first paragraph will become the subject header so
|
||||
# does not need to be checked.
|
||||
: subject
|
||||
n
|
||||
/${scissors_line}/q
|
||||
/${blank_line}/!b subject
|
||||
# Check the body of the message for problematic
|
||||
# prefixes.
|
||||
: body
|
||||
n
|
||||
/${scissors_line}/q
|
||||
/${comment_line}/b body
|
||||
/^diff -/{p;q;}
|
||||
/^Index: /{p;q;}
|
||||
b body
|
||||
" "$1")"
|
||||
if test -n "$line"
|
||||
then
|
||||
echo >&2 "Message contains a diff that will confuse 'git am'."
|
||||
echo >&2 "To fix this indent the diff."
|
||||
ret=1
|
||||
fi
|
||||
|
||||
exit $ret
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/perl
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
use IPC::Open2;
|
||||
|
||||
# An example hook script to integrate Watchman
|
||||
# (https://facebook.github.io/watchman/) with git to speed up detecting
|
||||
# new and modified files.
|
||||
#
|
||||
# The hook is passed a version (currently 2) and last update token
|
||||
# formatted as a string and outputs to stdout a new update token and
|
||||
# all files that have been modified since the update token. Paths must
|
||||
# be relative to the root of the working tree and separated by a single NUL.
|
||||
#
|
||||
# To enable this hook, rename this file to "query-watchman" and set
|
||||
# 'git config core.fsmonitor .git/hooks/query-watchman'
|
||||
#
|
||||
my ($version, $last_update_token) = @ARGV;
|
||||
|
||||
# Uncomment for debugging
|
||||
# print STDERR "$0 $version $last_update_token\n";
|
||||
|
||||
# Check the hook interface version
|
||||
if ($version ne 2) {
|
||||
die "Unsupported query-fsmonitor hook version '$version'.\n" .
|
||||
"Falling back to scanning...\n";
|
||||
}
|
||||
|
||||
my $git_work_tree = get_working_dir();
|
||||
|
||||
my $json_pkg;
|
||||
eval {
|
||||
require JSON::XS;
|
||||
$json_pkg = "JSON::XS";
|
||||
1;
|
||||
} or do {
|
||||
require JSON::PP;
|
||||
$json_pkg = "JSON::PP";
|
||||
};
|
||||
|
||||
launch_watchman();
|
||||
|
||||
sub launch_watchman {
|
||||
my $o = watchman_query();
|
||||
if (is_work_tree_watched($o)) {
|
||||
output_result($o->{clock}, @{$o->{files}});
|
||||
}
|
||||
}
|
||||
|
||||
sub output_result {
|
||||
my ($clockid, @files) = @_;
|
||||
|
||||
# Uncomment for debugging watchman output
|
||||
# open (my $fh, ">", ".git/watchman-output.out");
|
||||
# binmode $fh, ":utf8";
|
||||
# print $fh "$clockid\n@files\n";
|
||||
# close $fh;
|
||||
|
||||
binmode STDOUT, ":utf8";
|
||||
print $clockid;
|
||||
print "\0";
|
||||
local $, = "\0";
|
||||
print @files;
|
||||
}
|
||||
|
||||
sub watchman_clock {
|
||||
my $response = qx/watchman clock "$git_work_tree"/;
|
||||
die "Failed to get clock id on '$git_work_tree'.\n" .
|
||||
"Falling back to scanning...\n" if $? != 0;
|
||||
|
||||
return $json_pkg->new->utf8->decode($response);
|
||||
}
|
||||
|
||||
sub watchman_query {
|
||||
my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty')
|
||||
or die "open2() failed: $!\n" .
|
||||
"Falling back to scanning...\n";
|
||||
|
||||
# In the query expression below we're asking for names of files that
|
||||
# changed since $last_update_token but not from the .git folder.
|
||||
#
|
||||
# To accomplish this, we're using the "since" generator to use the
|
||||
# recency index to select candidate nodes and "fields" to limit the
|
||||
# output to file names only. Then we're using the "expression" term to
|
||||
# further constrain the results.
|
||||
my $last_update_line = "";
|
||||
if (substr($last_update_token, 0, 1) eq "c") {
|
||||
$last_update_token = "\"$last_update_token\"";
|
||||
$last_update_line = qq[\n"since": $last_update_token,];
|
||||
}
|
||||
my $query = <<" END";
|
||||
["query", "$git_work_tree", {$last_update_line
|
||||
"fields": ["name"],
|
||||
"expression": ["not", ["dirname", ".git"]]
|
||||
}]
|
||||
END
|
||||
|
||||
# Uncomment for debugging the watchman query
|
||||
# open (my $fh, ">", ".git/watchman-query.json");
|
||||
# print $fh $query;
|
||||
# close $fh;
|
||||
|
||||
print CHLD_IN $query;
|
||||
close CHLD_IN;
|
||||
my $response = do {local $/; <CHLD_OUT>};
|
||||
|
||||
# Uncomment for debugging the watch response
|
||||
# open ($fh, ">", ".git/watchman-response.json");
|
||||
# print $fh $response;
|
||||
# close $fh;
|
||||
|
||||
die "Watchman: command returned no output.\n" .
|
||||
"Falling back to scanning...\n" if $response eq "";
|
||||
die "Watchman: command returned invalid output: $response\n" .
|
||||
"Falling back to scanning...\n" unless $response =~ /^\{/;
|
||||
|
||||
return $json_pkg->new->utf8->decode($response);
|
||||
}
|
||||
|
||||
sub is_work_tree_watched {
|
||||
my ($output) = @_;
|
||||
my $error = $output->{error};
|
||||
if ($error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) {
|
||||
my $response = qx/watchman watch "$git_work_tree"/;
|
||||
die "Failed to make watchman watch '$git_work_tree'.\n" .
|
||||
"Falling back to scanning...\n" if $? != 0;
|
||||
$output = $json_pkg->new->utf8->decode($response);
|
||||
$error = $output->{error};
|
||||
die "Watchman: $error.\n" .
|
||||
"Falling back to scanning...\n" if $error;
|
||||
|
||||
# Uncomment for debugging watchman output
|
||||
# open (my $fh, ">", ".git/watchman-output.out");
|
||||
# close $fh;
|
||||
|
||||
# Watchman will always return all files on the first query so
|
||||
# return the fast "everything is dirty" flag to git and do the
|
||||
# Watchman query just to get it over with now so we won't pay
|
||||
# the cost in git to look up each individual file.
|
||||
my $o = watchman_clock();
|
||||
$error = $o->{error};
|
||||
|
||||
die "Watchman: $error.\n" .
|
||||
"Falling back to scanning...\n" if $error;
|
||||
|
||||
output_result($o->{clock}, ("/"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
die "Watchman: $error.\n" .
|
||||
"Falling back to scanning...\n" if $error;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
sub get_working_dir {
|
||||
my $working_dir;
|
||||
if ($^O =~ 'msys' || $^O =~ 'cygwin') {
|
||||
$working_dir = Win32::GetCwd();
|
||||
$working_dir =~ tr/\\/\//;
|
||||
} else {
|
||||
require Cwd;
|
||||
$working_dir = Cwd::cwd();
|
||||
}
|
||||
|
||||
return $working_dir;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to prepare a packed repository for use over
|
||||
# dumb transports.
|
||||
#
|
||||
# To enable this hook, rename this file to "post-update".
|
||||
|
||||
exec git update-server-info
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to verify what is about to be committed
|
||||
# by applypatch from an e-mail message.
|
||||
#
|
||||
# The hook should exit with non-zero status after issuing an
|
||||
# appropriate message if it wants to stop the commit.
|
||||
#
|
||||
# To enable this hook, rename this file to "pre-applypatch".
|
||||
|
||||
. git-sh-setup
|
||||
precommit="$(git rev-parse --git-path hooks/pre-commit)"
|
||||
test -x "$precommit" && exec "$precommit" ${1+"$@"}
|
||||
:
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to verify what is about to be committed.
|
||||
# Called by "git commit" with no arguments. The hook should
|
||||
# exit with non-zero status after issuing an appropriate message if
|
||||
# it wants to stop the commit.
|
||||
#
|
||||
# To enable this hook, rename this file to "pre-commit".
|
||||
|
||||
if git rev-parse --verify HEAD >/dev/null 2>&1
|
||||
then
|
||||
against=HEAD
|
||||
else
|
||||
# Initial commit: diff against an empty tree object
|
||||
against=$(git hash-object -t tree /dev/null)
|
||||
fi
|
||||
|
||||
# If you want to allow non-ASCII filenames set this variable to true.
|
||||
allownonascii=$(git config --type=bool hooks.allownonascii)
|
||||
|
||||
# Redirect output to stderr.
|
||||
exec 1>&2
|
||||
|
||||
# Cross platform projects tend to avoid non-ASCII filenames; prevent
|
||||
# them from being added to the repository. We exploit the fact that the
|
||||
# printable range starts at the space character and ends with tilde.
|
||||
if [ "$allownonascii" != "true" ] &&
|
||||
# Note that the use of brackets around a tr range is ok here, (it's
|
||||
# even required, for portability to Solaris 10's /usr/bin/tr), since
|
||||
# the square bracket bytes happen to fall in the designated range.
|
||||
test $(git diff-index --cached --name-only --diff-filter=A -z $against |
|
||||
LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
|
||||
then
|
||||
cat <<\EOF
|
||||
Error: Attempt to add a non-ASCII file name.
|
||||
|
||||
This can cause problems if you want to work with people on other platforms.
|
||||
|
||||
To be portable it is advisable to rename the file.
|
||||
|
||||
If you know what you are doing you can disable this check using:
|
||||
|
||||
git config hooks.allownonascii true
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# If there are whitespace errors, print the offending file names and fail.
|
||||
exec git diff-index --check --cached $against --
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to verify what is about to be committed.
|
||||
# Called by "git merge" with no arguments. The hook should
|
||||
# exit with non-zero status after issuing an appropriate message to
|
||||
# stderr if it wants to stop the merge commit.
|
||||
#
|
||||
# To enable this hook, rename this file to "pre-merge-commit".
|
||||
|
||||
. git-sh-setup
|
||||
test -x "$GIT_DIR/hooks/pre-commit" &&
|
||||
exec "$GIT_DIR/hooks/pre-commit"
|
||||
:
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
#!/bin/sh
|
||||
|
||||
# An example hook script to verify what is about to be pushed. Called by "git
|
||||
# push" after it has checked the remote status, but before anything has been
|
||||
# pushed. If this script exits with a non-zero status nothing will be pushed.
|
||||
#
|
||||
# This hook is called with the following parameters:
|
||||
#
|
||||
# $1 -- Name of the remote to which the push is being done
|
||||
# $2 -- URL to which the push is being done
|
||||
#
|
||||
# If pushing without using a named remote those arguments will be equal.
|
||||
#
|
||||
# Information about the commits which are being pushed is supplied as lines to
|
||||
# the standard input in the form:
|
||||
#
|
||||
# <local ref> <local oid> <remote ref> <remote oid>
|
||||
#
|
||||
# This sample shows how to prevent push of commits where the log message starts
|
||||
# with "WIP" (work in progress).
|
||||
|
||||
remote="$1"
|
||||
url="$2"
|
||||
|
||||
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
|
||||
|
||||
while read local_ref local_oid remote_ref remote_oid
|
||||
do
|
||||
if test "$local_oid" = "$zero"
|
||||
then
|
||||
# Handle delete
|
||||
:
|
||||
else
|
||||
if test "$remote_oid" = "$zero"
|
||||
then
|
||||
# New branch, examine all commits
|
||||
range="$local_oid"
|
||||
else
|
||||
# Update to existing branch, examine new commits
|
||||
range="$remote_oid..$local_oid"
|
||||
fi
|
||||
|
||||
# Check for WIP commit
|
||||
commit=$(git rev-list -n 1 --grep '^WIP' "$range")
|
||||
if test -n "$commit"
|
||||
then
|
||||
echo >&2 "Found WIP commit in $local_ref, not pushing"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
exit 0
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Copyright (c) 2006, 2008 Junio C Hamano
|
||||
#
|
||||
# The "pre-rebase" hook is run just before "git rebase" starts doing
|
||||
# its job, and can prevent the command from running by exiting with
|
||||
# non-zero status.
|
||||
#
|
||||
# The hook is called with the following parameters:
|
||||
#
|
||||
# $1 -- the upstream the series was forked from.
|
||||
# $2 -- the branch being rebased (or empty when rebasing the current branch).
|
||||
#
|
||||
# This sample shows how to prevent topic branches that are already
|
||||
# merged to 'next' branch from getting rebased, because allowing it
|
||||
# would result in rebasing already published history.
|
||||
|
||||
publish=next
|
||||
basebranch="$1"
|
||||
if test "$#" = 2
|
||||
then
|
||||
topic="refs/heads/$2"
|
||||
else
|
||||
topic=`git symbolic-ref HEAD` ||
|
||||
exit 0 ;# we do not interrupt rebasing detached HEAD
|
||||
fi
|
||||
|
||||
case "$topic" in
|
||||
refs/heads/??/*)
|
||||
;;
|
||||
*)
|
||||
exit 0 ;# we do not interrupt others.
|
||||
;;
|
||||
esac
|
||||
|
||||
# Now we are dealing with a topic branch being rebased
|
||||
# on top of master. Is it OK to rebase it?
|
||||
|
||||
# Does the topic really exist?
|
||||
git show-ref -q "$topic" || {
|
||||
echo >&2 "No such branch $topic"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Is topic fully merged to master?
|
||||
not_in_master=`git rev-list --pretty=oneline ^master "$topic"`
|
||||
if test -z "$not_in_master"
|
||||
then
|
||||
echo >&2 "$topic is fully merged to master; better remove it."
|
||||
exit 1 ;# we could allow it, but there is no point.
|
||||
fi
|
||||
|
||||
# Is topic ever merged to next? If so you should not be rebasing it.
|
||||
only_next_1=`git rev-list ^master "^$topic" ${publish} | sort`
|
||||
only_next_2=`git rev-list ^master ${publish} | sort`
|
||||
if test "$only_next_1" = "$only_next_2"
|
||||
then
|
||||
not_in_topic=`git rev-list "^$topic" master`
|
||||
if test -z "$not_in_topic"
|
||||
then
|
||||
echo >&2 "$topic is already up to date with master"
|
||||
exit 1 ;# we could allow it, but there is no point.
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"`
|
||||
/usr/bin/perl -e '
|
||||
my $topic = $ARGV[0];
|
||||
my $msg = "* $topic has commits already merged to public branch:\n";
|
||||
my (%not_in_next) = map {
|
||||
/^([0-9a-f]+) /;
|
||||
($1 => 1);
|
||||
} split(/\n/, $ARGV[1]);
|
||||
for my $elem (map {
|
||||
/^([0-9a-f]+) (.*)$/;
|
||||
[$1 => $2];
|
||||
} split(/\n/, $ARGV[2])) {
|
||||
if (!exists $not_in_next{$elem->[0]}) {
|
||||
if ($msg) {
|
||||
print STDERR $msg;
|
||||
undef $msg;
|
||||
}
|
||||
print STDERR " $elem->[1]\n";
|
||||
}
|
||||
}
|
||||
' "$topic" "$not_in_next" "$not_in_master"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
<<\DOC_END
|
||||
|
||||
This sample hook safeguards topic branches that have been
|
||||
published from being rewound.
|
||||
|
||||
The workflow assumed here is:
|
||||
|
||||
* Once a topic branch forks from "master", "master" is never
|
||||
merged into it again (either directly or indirectly).
|
||||
|
||||
* Once a topic branch is fully cooked and merged into "master",
|
||||
it is deleted. If you need to build on top of it to correct
|
||||
earlier mistakes, a new topic branch is created by forking at
|
||||
the tip of the "master". This is not strictly necessary, but
|
||||
it makes it easier to keep your history simple.
|
||||
|
||||
* Whenever you need to test or publish your changes to topic
|
||||
branches, merge them into "next" branch.
|
||||
|
||||
The script, being an example, hardcodes the publish branch name
|
||||
to be "next", but it is trivial to make it configurable via
|
||||
$GIT_DIR/config mechanism.
|
||||
|
||||
With this workflow, you would want to know:
|
||||
|
||||
(1) ... if a topic branch has ever been merged to "next". Young
|
||||
topic branches can have stupid mistakes you would rather
|
||||
clean up before publishing, and things that have not been
|
||||
merged into other branches can be easily rebased without
|
||||
affecting other people. But once it is published, you would
|
||||
not want to rewind it.
|
||||
|
||||
(2) ... if a topic branch has been fully merged to "master".
|
||||
Then you can delete it. More importantly, you should not
|
||||
build on top of it -- other people may already want to
|
||||
change things related to the topic as patches against your
|
||||
"master", so if you need further changes, it is better to
|
||||
fork the topic (perhaps with the same name) afresh from the
|
||||
tip of "master".
|
||||
|
||||
Let's look at this example:
|
||||
|
||||
o---o---o---o---o---o---o---o---o---o "next"
|
||||
/ / / /
|
||||
/ a---a---b A / /
|
||||
/ / / /
|
||||
/ / c---c---c---c B /
|
||||
/ / / \ /
|
||||
/ / / b---b C \ /
|
||||
/ / / / \ /
|
||||
---o---o---o---o---o---o---o---o---o---o---o "master"
|
||||
|
||||
|
||||
A, B and C are topic branches.
|
||||
|
||||
* A has one fix since it was merged up to "next".
|
||||
|
||||
* B has finished. It has been fully merged up to "master" and "next",
|
||||
and is ready to be deleted.
|
||||
|
||||
* C has not merged to "next" at all.
|
||||
|
||||
We would want to allow C to be rebased, refuse A, and encourage
|
||||
B to be deleted.
|
||||
|
||||
To compute (1):
|
||||
|
||||
git rev-list ^master ^topic next
|
||||
git rev-list ^master next
|
||||
|
||||
if these match, topic has not merged in next at all.
|
||||
|
||||
To compute (2):
|
||||
|
||||
git rev-list master..topic
|
||||
|
||||
if this is empty, it is fully merged to "master".
|
||||
|
||||
DOC_END
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to make use of push options.
|
||||
# The example simply echoes all push options that start with 'echoback='
|
||||
# and rejects all pushes when the "reject" push option is used.
|
||||
#
|
||||
# To enable this hook, rename this file to "pre-receive".
|
||||
|
||||
if test -n "$GIT_PUSH_OPTION_COUNT"
|
||||
then
|
||||
i=0
|
||||
while test "$i" -lt "$GIT_PUSH_OPTION_COUNT"
|
||||
do
|
||||
eval "value=\$GIT_PUSH_OPTION_$i"
|
||||
case "$value" in
|
||||
echoback=*)
|
||||
echo "echo from the pre-receive-hook: ${value#*=}" >&2
|
||||
;;
|
||||
reject)
|
||||
exit 1
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
fi
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to prepare the commit log message.
|
||||
# Called by "git commit" with the name of the file that has the
|
||||
# commit message, followed by the description of the commit
|
||||
# message's source. The hook's purpose is to edit the commit
|
||||
# message file. If the hook fails with a non-zero status,
|
||||
# the commit is aborted.
|
||||
#
|
||||
# To enable this hook, rename this file to "prepare-commit-msg".
|
||||
|
||||
# This hook includes three examples. The first one removes the
|
||||
# "# Please enter the commit message..." help message.
|
||||
#
|
||||
# The second includes the output of "git diff --name-status -r"
|
||||
# into the message, just before the "git status" output. It is
|
||||
# commented because it doesn't cope with --amend or with squashed
|
||||
# commits.
|
||||
#
|
||||
# The third example adds a Signed-off-by line to the message, that can
|
||||
# still be edited. This is rarely a good idea.
|
||||
|
||||
COMMIT_MSG_FILE=$1
|
||||
COMMIT_SOURCE=$2
|
||||
SHA1=$3
|
||||
|
||||
/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE"
|
||||
|
||||
# case "$COMMIT_SOURCE,$SHA1" in
|
||||
# ,|template,)
|
||||
# /usr/bin/perl -i.bak -pe '
|
||||
# print "\n" . `git diff --cached --name-status -r`
|
||||
# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;;
|
||||
# *) ;;
|
||||
# esac
|
||||
|
||||
# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
|
||||
# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE"
|
||||
# if test -z "$COMMIT_SOURCE"
|
||||
# then
|
||||
# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE"
|
||||
# fi
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/bin/sh
|
||||
|
||||
# An example hook script to update a checked-out tree on a git push.
|
||||
#
|
||||
# This hook is invoked by git-receive-pack(1) when it reacts to git
|
||||
# push and updates reference(s) in its repository, and when the push
|
||||
# tries to update the branch that is currently checked out and the
|
||||
# receive.denyCurrentBranch configuration variable is set to
|
||||
# updateInstead.
|
||||
#
|
||||
# By default, such a push is refused if the working tree and the index
|
||||
# of the remote repository has any difference from the currently
|
||||
# checked out commit; when both the working tree and the index match
|
||||
# the current commit, they are updated to match the newly pushed tip
|
||||
# of the branch. This hook is to be used to override the default
|
||||
# behaviour; however the code below reimplements the default behaviour
|
||||
# as a starting point for convenient modification.
|
||||
#
|
||||
# The hook receives the commit with which the tip of the current
|
||||
# branch is going to be updated:
|
||||
commit=$1
|
||||
|
||||
# It can exit with a non-zero status to refuse the push (when it does
|
||||
# so, it must not modify the index or the working tree).
|
||||
die () {
|
||||
echo >&2 "$*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Or it can make any necessary changes to the working tree and to the
|
||||
# index to bring them to the desired state when the tip of the current
|
||||
# branch is updated to the new commit, and exit with a zero status.
|
||||
#
|
||||
# For example, the hook can simply run git read-tree -u -m HEAD "$1"
|
||||
# in order to emulate git fetch that is run in the reverse direction
|
||||
# with git push, as the two-tree form of git read-tree -u -m is
|
||||
# essentially the same as git switch or git checkout that switches
|
||||
# branches while keeping the local changes in the working tree that do
|
||||
# not interfere with the difference between the branches.
|
||||
|
||||
# The below is a more-or-less exact translation to shell of the C code
|
||||
# for the default behaviour for git's push-to-checkout hook defined in
|
||||
# the push_to_deploy() function in builtin/receive-pack.c.
|
||||
#
|
||||
# Note that the hook will be executed from the repository directory,
|
||||
# not from the working tree, so if you want to perform operations on
|
||||
# the working tree, you will have to adapt your code accordingly, e.g.
|
||||
# by adding "cd .." or using relative paths.
|
||||
|
||||
if ! git update-index -q --ignore-submodules --refresh
|
||||
then
|
||||
die "Up-to-date check failed"
|
||||
fi
|
||||
|
||||
if ! git diff-files --quiet --ignore-submodules --
|
||||
then
|
||||
die "Working directory has unstaged changes"
|
||||
fi
|
||||
|
||||
# This is a rough translation of:
|
||||
#
|
||||
# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX
|
||||
if git cat-file -e HEAD 2>/dev/null
|
||||
then
|
||||
head=HEAD
|
||||
else
|
||||
head=$(git hash-object -t tree --stdin </dev/null)
|
||||
fi
|
||||
|
||||
if ! git diff-index --quiet --cached --ignore-submodules $head --
|
||||
then
|
||||
die "Working directory has staged changes"
|
||||
fi
|
||||
|
||||
if ! git read-tree -u -m "$commit"
|
||||
then
|
||||
die "Could not update working tree to new HEAD"
|
||||
fi
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/bin/sh
|
||||
|
||||
# An example hook script to validate a patch (and/or patch series) before
|
||||
# sending it via email.
|
||||
#
|
||||
# The hook should exit with non-zero status after issuing an appropriate
|
||||
# message if it wants to prevent the email(s) from being sent.
|
||||
#
|
||||
# To enable this hook, rename this file to "sendemail-validate".
|
||||
#
|
||||
# By default, it will only check that the patch(es) can be applied on top of
|
||||
# the default upstream branch without conflicts in a secondary worktree. After
|
||||
# validation (successful or not) of the last patch of a series, the worktree
|
||||
# will be deleted.
|
||||
#
|
||||
# The following config variables can be set to change the default remote and
|
||||
# remote ref that are used to apply the patches against:
|
||||
#
|
||||
# sendemail.validateRemote (default: origin)
|
||||
# sendemail.validateRemoteRef (default: HEAD)
|
||||
#
|
||||
# Replace the TODO placeholders with appropriate checks according to your
|
||||
# needs.
|
||||
|
||||
validate_cover_letter () {
|
||||
file="$1"
|
||||
# TODO: Replace with appropriate checks (e.g. spell checking).
|
||||
true
|
||||
}
|
||||
|
||||
validate_patch () {
|
||||
file="$1"
|
||||
# Ensure that the patch applies without conflicts.
|
||||
git am -3 "$file" || return
|
||||
# TODO: Replace with appropriate checks for this patch
|
||||
# (e.g. checkpatch.pl).
|
||||
true
|
||||
}
|
||||
|
||||
validate_series () {
|
||||
# TODO: Replace with appropriate checks for the whole series
|
||||
# (e.g. quick build, coding style checks, etc.).
|
||||
true
|
||||
}
|
||||
|
||||
# main -------------------------------------------------------------------------
|
||||
|
||||
if test "$GIT_SENDEMAIL_FILE_COUNTER" = 1
|
||||
then
|
||||
remote=$(git config --default origin --get sendemail.validateRemote) &&
|
||||
ref=$(git config --default HEAD --get sendemail.validateRemoteRef) &&
|
||||
worktree=$(mktemp --tmpdir -d sendemail-validate.XXXXXXX) &&
|
||||
git worktree add -fd --checkout "$worktree" "refs/remotes/$remote/$ref" &&
|
||||
git config --replace-all sendemail.validateWorktree "$worktree"
|
||||
else
|
||||
worktree=$(git config --get sendemail.validateWorktree)
|
||||
fi || {
|
||||
echo "sendemail-validate: error: failed to prepare worktree" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
unset GIT_DIR GIT_WORK_TREE
|
||||
cd "$worktree" &&
|
||||
|
||||
if grep -q "^diff --git " "$1"
|
||||
then
|
||||
validate_patch "$1"
|
||||
else
|
||||
validate_cover_letter "$1"
|
||||
fi &&
|
||||
|
||||
if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL"
|
||||
then
|
||||
git config --unset-all sendemail.validateWorktree &&
|
||||
trap 'git worktree remove -ff "$worktree"' EXIT &&
|
||||
validate_series
|
||||
fi
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to block unannotated tags from entering.
|
||||
# Called by "git receive-pack" with arguments: refname sha1-old sha1-new
|
||||
#
|
||||
# To enable this hook, rename this file to "update".
|
||||
#
|
||||
# Config
|
||||
# ------
|
||||
# hooks.allowunannotated
|
||||
# This boolean sets whether unannotated tags will be allowed into the
|
||||
# repository. By default they won't be.
|
||||
# hooks.allowdeletetag
|
||||
# This boolean sets whether deleting tags will be allowed in the
|
||||
# repository. By default they won't be.
|
||||
# hooks.allowmodifytag
|
||||
# This boolean sets whether a tag may be modified after creation. By default
|
||||
# it won't be.
|
||||
# hooks.allowdeletebranch
|
||||
# This boolean sets whether deleting branches will be allowed in the
|
||||
# repository. By default they won't be.
|
||||
# hooks.denycreatebranch
|
||||
# This boolean sets whether remotely creating branches will be denied
|
||||
# in the repository. By default this is allowed.
|
||||
#
|
||||
|
||||
# --- Command line
|
||||
refname="$1"
|
||||
oldrev="$2"
|
||||
newrev="$3"
|
||||
|
||||
# --- Safety check
|
||||
if [ -z "$GIT_DIR" ]; then
|
||||
echo "Don't run this script from the command line." >&2
|
||||
echo " (if you want, you could supply GIT_DIR then run" >&2
|
||||
echo " $0 <ref> <oldrev> <newrev>)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then
|
||||
echo "usage: $0 <ref> <oldrev> <newrev>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Config
|
||||
allowunannotated=$(git config --type=bool hooks.allowunannotated)
|
||||
allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch)
|
||||
denycreatebranch=$(git config --type=bool hooks.denycreatebranch)
|
||||
allowdeletetag=$(git config --type=bool hooks.allowdeletetag)
|
||||
allowmodifytag=$(git config --type=bool hooks.allowmodifytag)
|
||||
|
||||
# check for no description
|
||||
projectdesc=$(sed -e '1q' "$GIT_DIR/description")
|
||||
case "$projectdesc" in
|
||||
"Unnamed repository"* | "")
|
||||
echo "*** Project description file hasn't been set" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- Check types
|
||||
# if $newrev is 0000...0000, it's a commit to delete a ref.
|
||||
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
|
||||
if [ "$newrev" = "$zero" ]; then
|
||||
newrev_type=delete
|
||||
else
|
||||
newrev_type=$(git cat-file -t $newrev)
|
||||
fi
|
||||
|
||||
case "$refname","$newrev_type" in
|
||||
refs/tags/*,commit)
|
||||
# un-annotated tag
|
||||
short_refname=${refname##refs/tags/}
|
||||
if [ "$allowunannotated" != "true" ]; then
|
||||
echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2
|
||||
echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
refs/tags/*,delete)
|
||||
# delete tag
|
||||
if [ "$allowdeletetag" != "true" ]; then
|
||||
echo "*** Deleting a tag is not allowed in this repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
refs/tags/*,tag)
|
||||
# annotated tag
|
||||
if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1
|
||||
then
|
||||
echo "*** Tag '$refname' already exists." >&2
|
||||
echo "*** Modifying a tag is not allowed in this repository." >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
refs/heads/*,commit)
|
||||
# branch
|
||||
if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then
|
||||
echo "*** Creating a branch is not allowed in this repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
refs/heads/*,delete)
|
||||
# delete branch
|
||||
if [ "$allowdeletebranch" != "true" ]; then
|
||||
echo "*** Deleting a branch is not allowed in this repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
refs/remotes/*,commit)
|
||||
# tracking branch
|
||||
;;
|
||||
refs/remotes/*,delete)
|
||||
# delete tracking branch
|
||||
if [ "$allowdeletebranch" != "true" ]; then
|
||||
echo "*** Deleting a tracking branch is not allowed in this repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# Anything else (is there anything else?)
|
||||
echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- Finished
|
||||
exit 0
|
||||
@@ -0,0 +1,6 @@
|
||||
# git ls-files --others --exclude-from=.git/info/exclude
|
||||
# Lines that start with '#' are comments.
|
||||
# For a project mostly in C, the following would be a good set of
|
||||
# exclude patterns (uncomment them if you want to use them):
|
||||
# *.[oa]
|
||||
# *~
|
||||
@@ -26,6 +26,9 @@ const RepoSettingsPage = lazy(() => import('./pages/RepoSettingsPage'))
|
||||
const RepoIssuesPage = lazy(() => import('./pages/RepoIssuesPage'))
|
||||
const RepoPRsPage = lazy(() => import('./pages/RepoPRsPage'))
|
||||
const PRDetailPage = lazy(() => import('./pages/PRDetailPage'))
|
||||
const CommitsPage = lazy(() => import('./pages/CommitsPage'))
|
||||
const BranchesPage = lazy(() => import('./pages/BranchesPage'))
|
||||
const StarredPage = lazy(() => import('./pages/StarredPage'))
|
||||
const PRsPage = lazy(() => import('./pages/PRsPage'))
|
||||
const PipelinesPage = lazy(() => import('./pages/PipelinesPage'))
|
||||
const ProfilePage = lazy(() => import('./pages/ProfilePage'))
|
||||
@@ -65,11 +68,14 @@ export default function App() {
|
||||
|
||||
<Route path="repos" element={<S><ReposPage /></S>} />
|
||||
<Route path="repos/:owner/:repo" element={<S><RepoPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/commits" element={<S><CommitsPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/branches" element={<S><BranchesPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/settings" element={<S><RepoSettingsPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/issues" element={<S><RepoIssuesPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/pulls" element={<S><RepoPRsPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
|
||||
|
||||
<Route path="starred" element={<S><StarredPage /></S>} />
|
||||
<Route path="pulls" element={<S><PRsPage /></S>} />
|
||||
<Route path="pipelines" element={<S><PipelinesPage /></S>} />
|
||||
<Route path="explore" element={<S><ExplorePage /></S>} />
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Header } from './Header'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { BottomTabBar } from './BottomTabBar'
|
||||
|
||||
export function AppShell() {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-[#FAFBFC]">
|
||||
{/* Desktop sidebar — hidden below md breakpoint */}
|
||||
<Sidebar className="hidden md:flex" />
|
||||
<div className="flex flex-col h-screen overflow-hidden bg-[#F4F5F7]">
|
||||
{/* Top header — full width, always visible */}
|
||||
<Header />
|
||||
|
||||
{/* Main content area — bottom padding leaves room for mobile tab bar */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Desktop sidebar */}
|
||||
<Sidebar className="hidden md:flex flex-col" />
|
||||
|
||||
{/* Main content */}
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex-1 overflow-y-auto pb-14 md:pb-0"
|
||||
@@ -16,8 +21,9 @@ export function AppShell() {
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom tab bar — hidden above md breakpoint */}
|
||||
{/* Mobile bottom tab bar */}
|
||||
<BottomTabBar className="md:hidden" />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useCreateRepo } from '../../api/queries/repos'
|
||||
|
||||
export function Header() {
|
||||
const { user, isAuthenticated } = useAuth()
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (search.trim()) {
|
||||
navigate(`/explore?q=${encodeURIComponent(search.trim())}`)
|
||||
setSearch('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="h-12 bg-[#1A2634] flex items-center gap-3 px-3 shrink-0 z-40 relative">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-7 h-7 rounded bg-[#0052CC] flex items-center justify-center text-white font-bold text-sm">F</div>
|
||||
<span className="text-white font-semibold text-sm hidden sm:block">ForgeBucket</span>
|
||||
</Link>
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="flex-1 max-w-xl">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
|
||||
width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search repositories, issues, pull requests…"
|
||||
className="w-full bg-white/10 text-white placeholder-white/40 text-xs rounded pl-8 pr-3 py-1.5 focus:outline-none focus:bg-white/15 focus:ring-1 focus:ring-white/30"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-1 ml-auto shrink-0">
|
||||
{/* Create */}
|
||||
{isAuthenticated && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowCreate(s => !s)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded bg-[#0052CC] hover:bg-[#0065FF] text-white text-xs font-semibold transition-colors"
|
||||
>
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Create
|
||||
</button>
|
||||
{showCreate && (
|
||||
<CreateMenu onClose={() => setShowCreate(false)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notifications (placeholder) */}
|
||||
<button className="w-8 h-8 rounded flex items-center justify-center text-white/60 hover:bg-white/10 hover:text-white transition-colors">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Settings */}
|
||||
<Link to="/settings" className="w-8 h-8 rounded flex items-center justify-center text-white/60 hover:bg-white/10 hover:text-white transition-colors">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
|
||||
{/* Avatar */}
|
||||
{isAuthenticated ? (
|
||||
<Link to="/profile" className="w-7 h-7 rounded-full bg-[#0052CC] flex items-center justify-center text-white text-xs font-bold hover:ring-2 hover:ring-white/30 transition-all">
|
||||
{user?.username?.[0]?.toUpperCase()}
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/login" className="px-3 py-1.5 rounded text-white/80 hover:text-white text-xs font-medium hover:bg-white/10 transition-colors">
|
||||
Sign in
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Click-away for create menu */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 z-30" onClick={() => setShowCreate(false)} />
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateMenu({ onClose }: { onClose: () => void }) {
|
||||
const navigate = useNavigate()
|
||||
const createRepo = useCreateRepo()
|
||||
const [name, setName] = useState('')
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
const repo = await createRepo.mutateAsync({ name: name.trim() })
|
||||
onClose()
|
||||
navigate(`/repos/${repo.ownerName}/${repo.name}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute right-0 top-full mt-1 w-72 bg-white rounded-lg shadow-xl border border-[#DFE1E6] z-50 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[#DFE1E6]">
|
||||
<p className="text-xs font-semibold text-[#5E6C84] uppercase tracking-wide">Quick create</p>
|
||||
</div>
|
||||
<form onSubmit={handleCreate} className="p-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Repository name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="my-new-repo"
|
||||
autoFocus
|
||||
required
|
||||
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createRepo.isPending || !name.trim()}
|
||||
className="w-full py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] disabled:opacity-50"
|
||||
>
|
||||
{createRepo.isPending ? 'Creating…' : 'Create repository'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="border-t border-[#DFE1E6]">
|
||||
<Link to="/repos" onClick={onClose}
|
||||
className="flex items-center gap-2 px-4 py-3 text-sm text-[#172B4D] hover:bg-[#F4F5F7]">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />
|
||||
</svg>
|
||||
All repositories
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,192 +1,195 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink, Link } from 'react-router-dom'
|
||||
import { NavLink, Link, useMatch } from 'react-router-dom'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
|
||||
type SidebarState = 'expanded' | 'collapsed' | 'hidden'
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
href: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/',
|
||||
icon: (
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Repositories',
|
||||
href: '/repos',
|
||||
icon: (
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Pull Requests',
|
||||
href: '/pulls',
|
||||
icon: (
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Pipelines',
|
||||
href: '/pipelines',
|
||||
icon: (
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Explore',
|
||||
href: '/explore',
|
||||
icon: (
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
import { useRecentRepos } from '../../hooks/useRecentRepos'
|
||||
import { useStarredRepos } from '../../hooks/useStarredRepos'
|
||||
|
||||
interface SidebarProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Sidebar({ className }: SidebarProps) {
|
||||
const [state, setState] = useState<SidebarState>('expanded')
|
||||
const { user, isAuthenticated } = useAuth()
|
||||
const { repos: recentRepos } = useRecentRepos()
|
||||
const { toggle, isStarred } = useStarredRepos()
|
||||
const [openRecent, setOpenRecent] = useState(true)
|
||||
|
||||
const isCollapsed = state === 'collapsed'
|
||||
const width = isCollapsed ? 'w-14' : 'w-80'
|
||||
// Detect if we're inside a repo
|
||||
const repoMatch = useMatch('/repos/:owner/:repo/*')
|
||||
const currentOwner = repoMatch?.params.owner
|
||||
const currentRepo = repoMatch?.params.repo
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'relative flex flex-col h-full bg-[#172B4D] text-white transition-[width] duration-200 ease-in-out overflow-hidden shrink-0',
|
||||
width,
|
||||
className,
|
||||
)}
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
{/* Logo + toggle */}
|
||||
<div className="flex items-center h-14 px-3 border-b border-white/10 shrink-0">
|
||||
{!isCollapsed && (
|
||||
<span className="flex-1 text-sm font-semibold tracking-wide truncate">
|
||||
ForgeBucket
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setState(isCollapsed ? 'expanded' : 'collapsed')}
|
||||
className="flex items-center justify-center w-8 h-8 rounded hover:bg-white/10 transition-colors"
|
||||
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={cn('transition-transform duration-200', isCollapsed && 'rotate-180')}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-2 overflow-y-auto">
|
||||
<ul className="flex flex-col gap-0.5 px-2">
|
||||
{navItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
end={item.href === '/'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded px-2 transition-colors',
|
||||
'min-h-[44px]', // WCAG touch target
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white',
|
||||
)
|
||||
}
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
>
|
||||
<span className="shrink-0">{item.icon}</span>
|
||||
{!isCollapsed && (
|
||||
<span className="text-sm font-medium truncate">{item.label}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Bottom: user + settings */}
|
||||
<div className="py-2 px-2 border-t border-white/10 shrink-0 flex flex-col gap-0.5">
|
||||
{isAuthenticated ? (
|
||||
<NavLink
|
||||
to="/profile"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded px-2 min-h-[44px] transition-colors',
|
||||
isActive ? 'bg-white/20 text-white' : 'text-white/70 hover:bg-white/10 hover:text-white',
|
||||
)
|
||||
}
|
||||
title={isCollapsed ? user?.username : undefined}
|
||||
>
|
||||
<div className="w-5 h-5 rounded-full bg-[#0052CC] flex items-center justify-center text-[10px] font-bold shrink-0">
|
||||
<aside className={cn('w-60 bg-[#1A2634] flex flex-col h-full overflow-y-auto shrink-0', className)}>
|
||||
{/* User workspace header */}
|
||||
{isAuthenticated && (
|
||||
<div className="px-3 py-3 border-b border-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded bg-[#0052CC] flex items-center justify-center text-white text-[10px] font-bold shrink-0">
|
||||
{user?.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
{!isCollapsed && <span className="text-sm font-medium truncate">{user?.username}</span>}
|
||||
</NavLink>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="flex items-center gap-3 rounded px-2 min-h-[44px] text-white/70 hover:bg-white/10 hover:text-white transition-colors"
|
||||
title={isCollapsed ? 'Sign in' : undefined}
|
||||
>
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l-3 3m0 0 3 3m-3-3h12.75" />
|
||||
</svg>
|
||||
{!isCollapsed && <span className="text-sm font-medium">Sign in</span>}
|
||||
</Link>
|
||||
<span className="text-white text-xs font-semibold truncate">{user?.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded px-2 min-h-[44px] transition-colors',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white',
|
||||
)
|
||||
}
|
||||
title={isCollapsed ? 'Settings' : undefined}
|
||||
<nav className="flex-1 py-2">
|
||||
{/* ── Global nav ─────────────────────────────────────────────── */}
|
||||
<SidebarItem to="/" icon={<HomeIcon />} label="For you" end />
|
||||
<SidebarItem to="/pulls" icon={<PRIcon />} label="Pull requests" />
|
||||
<SidebarItem to="/repos" icon={<RepoIcon />} label="Repositories" />
|
||||
<SidebarItem to="/explore" icon={<ExploreIcon />} label="Explore" />
|
||||
<SidebarItem to="/starred" icon={<StarIcon />} label="Starred" />
|
||||
|
||||
{/* ── Recent repos ───────────────────────────────────────────── */}
|
||||
{recentRepos.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
onClick={() => setOpenRecent(o => !o)}
|
||||
className="flex items-center justify-between w-full px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40 hover:text-white/70 transition-colors"
|
||||
>
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<span>Recent</span>
|
||||
<svg width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"
|
||||
className={cn('transition-transform', openRecent ? '' : '-rotate-90')}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
{openRecent && recentRepos.map(r => (
|
||||
<RecentRepoItem
|
||||
key={`${r.ownerName}/${r.name}`}
|
||||
ownerName={r.ownerName}
|
||||
name={r.name}
|
||||
isActive={r.ownerName === currentOwner && r.name === currentRepo}
|
||||
isStarred={isStarred(r.ownerName, r.name)}
|
||||
onStar={() => toggle(r.ownerName, r.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Repo context sub-nav ────────────────────────────────────── */}
|
||||
{currentOwner && currentRepo && (
|
||||
<div className="mt-3 border-t border-white/10 pt-3">
|
||||
<p className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40 truncate">
|
||||
{currentOwner}/{currentRepo}
|
||||
</p>
|
||||
<RepoSubNav owner={currentOwner} repo={currentRepo} />
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* ── Bottom: customize ─────────────────────────────────────────── */}
|
||||
<div className="border-t border-white/10 py-2">
|
||||
<Link to="/settings"
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs text-white/50 hover:text-white/80 hover:bg-white/5 transition-colors rounded mx-1">
|
||||
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
{!isCollapsed && (
|
||||
<span className="text-sm font-medium">Settings</span>
|
||||
)}
|
||||
</NavLink>
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shared sub-components ────────────────────────────────────────────────────
|
||||
|
||||
function SidebarItem({ to, icon, label, end }: { to: string; icon: React.ReactNode; label: string; end?: boolean }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) => cn(
|
||||
'flex items-center gap-2.5 px-3 py-2 mx-1 rounded text-sm transition-colors min-h-[36px]',
|
||||
isActive
|
||||
? 'bg-white/12 text-white font-medium'
|
||||
: 'text-white/65 hover:bg-white/8 hover:text-white',
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 opacity-80">{icon}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentRepoItem({ ownerName, name, isActive, isStarred, onStar }: {
|
||||
ownerName: string; name: string; isActive: boolean; isStarred: boolean; onStar: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'group flex items-center gap-2 mx-1 rounded transition-colors',
|
||||
isActive ? 'bg-white/12' : 'hover:bg-white/8',
|
||||
)}>
|
||||
<Link to={`/repos/${ownerName}/${name}`}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 px-3 py-1.5">
|
||||
<div className="w-4 h-4 rounded-sm bg-[#0052CC]/70 flex items-center justify-center shrink-0">
|
||||
<svg width="9" height="9" fill="white" viewBox="0 0 24 24">
|
||||
<path d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xs text-white/75 truncate">{name}</span>
|
||||
</Link>
|
||||
<button onClick={onStar}
|
||||
className={cn('mr-2 opacity-0 group-hover:opacity-100 transition-opacity', isStarred && 'opacity-100')}>
|
||||
<svg width="12" height="12" fill={isStarred ? '#F79009' : 'none'} stroke={isStarred ? '#F79009' : 'rgba(255,255,255,0.5)'}
|
||||
strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RepoSubNav({ owner, repo }: { owner: string; repo: string }) {
|
||||
const base = `/repos/${owner}/${repo}`
|
||||
const items = [
|
||||
{ label: 'Source', to: base, icon: <SourceIcon />, end: true },
|
||||
{ label: 'Commits', to: `${base}/commits`, icon: <CommitsIcon /> },
|
||||
{ label: 'Branches', to: `${base}/branches`, icon: <BranchIcon /> },
|
||||
{ label: 'Pull requests', to: `${base}/pulls`, icon: <PRIcon /> },
|
||||
{ label: 'Issues', to: `${base}/issues`, icon: <IssueIcon /> },
|
||||
{ label: 'Pipelines', to: `${base}/pipelines`, icon: <PipelineIcon /> },
|
||||
{ label: 'Settings', to: `${base}/settings`, icon: <SettingsSmIcon /> },
|
||||
]
|
||||
return (
|
||||
<div>
|
||||
{items.map(item => (
|
||||
<NavLink key={item.to} to={item.to} end={item.end}
|
||||
className={({ isActive }) => cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 mx-1 rounded text-xs transition-colors',
|
||||
isActive
|
||||
? 'bg-white/12 text-white'
|
||||
: 'text-white/55 hover:bg-white/8 hover:text-white/90',
|
||||
)}>
|
||||
<span className="shrink-0">{item.icon}</span>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Icons ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const I = ({ d, filled }: { d: string | string[]; filled?: boolean }) => (
|
||||
<svg width="16" height="16" fill={filled ? 'currentColor' : 'none'} stroke={filled ? 'none' : 'currentColor'}
|
||||
strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
{(Array.isArray(d) ? d : [d]).map((path, i) => (
|
||||
<path key={i} strokeLinecap="round" strokeLinejoin="round" d={path} />
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
|
||||
const HomeIcon = () => <I d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||
const PRIcon = () => <I d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
const RepoIcon = () => <I d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />
|
||||
const ExploreIcon = () => <I d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
const StarIcon = () => <I d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
|
||||
const SourceIcon = () => <I d={['M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5']} />
|
||||
const CommitsIcon = () => <I d={['M12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z', 'M12 21.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z', 'M12 3.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z']} />
|
||||
const BranchIcon = () => <I d="M3 13.5V6a2.25 2.25 0 0 1 2.25-2.25h.75a2.25 2.25 0 0 1 2.25 2.25v3.75A2.25 2.25 0 0 1 6 12H5.25A2.25 2.25 0 0 0 3 14.25v2.25A2.25 2.25 0 0 0 5.25 18.75H6a2.25 2.25 0 0 0 2.25-2.25V15m0 0a3 3 0 1 0 6 0 3 3 0 0 0-6 0Zm0 0h3" />
|
||||
const IssueIcon = () => <I d={['M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z']} />
|
||||
const PipelineIcon = () => <I d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||
const SettingsSmIcon = () => <I d={['M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z', 'M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z']} />
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export interface RecentRepo {
|
||||
ownerName: string
|
||||
name: string
|
||||
visitedAt: number
|
||||
}
|
||||
|
||||
const KEY = 'fb_recent_repos'
|
||||
const MAX = 5
|
||||
|
||||
function read(): RecentRepo[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEY) ?? '[]')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function useRecentRepos() {
|
||||
const repos = read()
|
||||
|
||||
const track = useCallback((ownerName: string, name: string) => {
|
||||
const existing = read().filter(r => !(r.ownerName === ownerName && r.name === name))
|
||||
const updated: RecentRepo[] = [{ ownerName, name, visitedAt: Date.now() }, ...existing].slice(0, MAX)
|
||||
localStorage.setItem(KEY, JSON.stringify(updated))
|
||||
}, [])
|
||||
|
||||
return { repos, track }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
const KEY = 'fb_starred_repos'
|
||||
|
||||
function read(): string[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEY) ?? '[]')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function repoKey(ownerName: string, name: string) {
|
||||
return `${ownerName}/${name}`
|
||||
}
|
||||
|
||||
export function useStarredRepos() {
|
||||
const [starred, setStarred] = useState<string[]>(read)
|
||||
|
||||
const toggle = useCallback((ownerName: string, name: string) => {
|
||||
const key = repoKey(ownerName, name)
|
||||
setStarred(prev => {
|
||||
const next = prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||
localStorage.setItem(KEY, JSON.stringify(next))
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isStarred = useCallback((ownerName: string, name: string) => {
|
||||
return starred.includes(repoKey(ownerName, name))
|
||||
}, [starred])
|
||||
|
||||
return { starred, toggle, isStarred }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { api } from '../api/client'
|
||||
import { useRecentRepos } from '../hooks/useRecentRepos'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const branchSchema = z.object({ name: z.string() })
|
||||
|
||||
export default function BranchesPage() {
|
||||
const { owner = '', repo = '' } = useParams<{ owner: string; repo: string }>()
|
||||
const { track } = useRecentRepos()
|
||||
|
||||
useEffect(() => { if (owner && repo) track(owner, repo) }, [owner, repo])
|
||||
|
||||
const { data: branches, isLoading, isError } = useQuery({
|
||||
queryKey: ['repos', owner, repo, 'branches'],
|
||||
queryFn: () => api.get(`/api/v1/repos/${owner}/${repo}/branches`, z.array(branchSchema)),
|
||||
enabled: Boolean(owner && repo),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6">
|
||||
<div className="flex items-center gap-1 text-sm mb-4">
|
||||
<Link to={`/repos/${owner}/${repo}`} className="text-[#0052CC] hover:underline">{repo}</Link>
|
||||
<span className="text-[#5E6C84]">/</span>
|
||||
<span className="font-semibold text-[#172B4D]">Branches</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-xl font-semibold text-[#172B4D] mb-4">Branches</h1>
|
||||
|
||||
{isLoading && <p className="text-sm text-[#5E6C84]">Loading branches…</p>}
|
||||
{isError && <p className="text-sm text-[#DE350B]">Failed to load branches.</p>}
|
||||
{!isLoading && !branches?.length && (
|
||||
<p className="text-sm text-[#5E6C84] py-8 text-center">No branches yet.</p>
|
||||
)}
|
||||
|
||||
{branches && branches.length > 0 && (
|
||||
<div className="border border-[#DFE1E6] rounded overflow-hidden bg-white">
|
||||
{branches.map((branch, i) => (
|
||||
<div key={branch.name}
|
||||
className={`flex items-center gap-3 px-4 py-3 ${i > 0 ? 'border-t border-[#DFE1E6]' : ''} hover:bg-[#FAFBFC]`}>
|
||||
<svg width="14" height="14" fill="none" stroke="#5E6C84" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.5V6a2.25 2.25 0 0 1 2.25-2.25h.75a2.25 2.25 0 0 1 2.25 2.25v3.75A2.25 2.25 0 0 1 6 12H5.25A2.25 2.25 0 0 0 3 14.25v2.25A2.25 2.25 0 0 0 5.25 18.75H6a2.25 2.25 0 0 0 2.25-2.25V15m0 0a3 3 0 1 0 6 0 3 3 0 0 0-6 0Zm0 0h3" />
|
||||
</svg>
|
||||
<Link to={`/repos/${owner}/${repo}?ref=${branch.name}`}
|
||||
className="text-sm text-[#0052CC] hover:underline font-mono">
|
||||
{branch.name}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { api } from '../api/client'
|
||||
import { useRecentRepos } from '../hooks/useRecentRepos'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const commitSchema = z.object({
|
||||
hash: z.string(),
|
||||
author: z.string(),
|
||||
message: z.string(),
|
||||
date: z.string(),
|
||||
})
|
||||
|
||||
export default function CommitsPage() {
|
||||
const { owner = '', repo = '' } = useParams<{ owner: string; repo: string }>()
|
||||
const { track } = useRecentRepos()
|
||||
|
||||
useEffect(() => { if (owner && repo) track(owner, repo) }, [owner, repo])
|
||||
|
||||
const { data: commits, isLoading, isError } = useQuery({
|
||||
queryKey: ['repos', owner, repo, 'commits'],
|
||||
queryFn: () => api.get(`/api/v1/repos/${owner}/${repo}/commits?limit=50`, z.array(commitSchema)),
|
||||
enabled: Boolean(owner && repo),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6">
|
||||
<div className="flex items-center gap-1 text-sm mb-4">
|
||||
<Link to={`/repos/${owner}/${repo}`} className="text-[#0052CC] hover:underline">{repo}</Link>
|
||||
<span className="text-[#5E6C84]">/</span>
|
||||
<span className="font-semibold text-[#172B4D]">Commits</span>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-[#5E6C84]">Loading commits…</p>}
|
||||
{isError && <p className="text-sm text-[#DE350B]">Failed to load commits.</p>}
|
||||
{!isLoading && !commits?.length && (
|
||||
<p className="text-sm text-[#5E6C84] py-8 text-center">No commits yet. Push your first commit to get started.</p>
|
||||
)}
|
||||
|
||||
{commits && commits.length > 0 && (
|
||||
<div className="border border-[#DFE1E6] rounded overflow-hidden bg-white">
|
||||
{commits.map((commit, i) => (
|
||||
<div key={commit.hash}
|
||||
className={`flex items-start gap-4 px-4 py-3 ${i > 0 ? 'border-t border-[#DFE1E6]' : ''} hover:bg-[#FAFBFC]`}>
|
||||
<div className="w-7 h-7 rounded-full bg-[#0052CC]/10 text-[#0052CC] flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">
|
||||
{commit.author?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[#172B4D] truncate">{commit.message}</p>
|
||||
<p className="text-xs text-[#5E6C84] mt-0.5">
|
||||
{commit.author} · {new Date(commit.date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<code className="text-xs font-mono text-[#5E6C84] bg-[#F4F5F7] px-2 py-0.5 rounded shrink-0">
|
||||
{commit.hash.slice(0, 7)}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +1,133 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useRepos } from '../api/queries/repos'
|
||||
import { usePRs } from '../api/queries/prs'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { RepoCard } from '../components/repos/RepoCard'
|
||||
import { RepoListSkeleton } from '../ui/Skeleton'
|
||||
import { RepoListSkeleton, PRListSkeleton } from '../ui/Skeleton'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: repos, isLoading } = useRepos()
|
||||
const { user, isAuthenticated } = useAuth()
|
||||
const { data: repos, isLoading: reposLoading } = useRepos()
|
||||
|
||||
const hasRepos = repos && repos.length > 0
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6 space-y-8">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-[#172B4D]">Dashboard</h1>
|
||||
<p className="text-sm text-[#5E6C84] mt-1">Your repositories and recent activity.</p>
|
||||
</div>
|
||||
|
||||
{/* Hero — only when no repos yet */}
|
||||
{!reposLoading && !hasRepos && isAuthenticated && (
|
||||
<div className="rounded-lg border border-[#DFE1E6] bg-white overflow-hidden">
|
||||
<div className="flex items-center gap-8 p-8">
|
||||
<HeroIllustration />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-[#172B4D]">
|
||||
Welcome to ForgeBucket{user?.username ? `, ${user.username}` : ''}!
|
||||
</h1>
|
||||
<p className="text-sm text-[#5E6C84] mt-2 max-w-md">
|
||||
Get started by creating your first repository, pushing code, and collaborating through pull requests.
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-5">
|
||||
<Link to="/repos"
|
||||
className="px-4 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] min-h-[36px] flex items-center">
|
||||
Create repository
|
||||
</Link>
|
||||
<Link to="/explore"
|
||||
className="px-4 py-2 rounded border border-[#DFE1E6] text-sm text-[#172B4D] font-medium hover:bg-[#F4F5F7] min-h-[36px] flex items-center">
|
||||
Explore
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent repositories */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-[#172B4D] uppercase tracking-wide">
|
||||
Your Repositories
|
||||
<h2 className="text-sm font-semibold text-[#172B4D] flex items-center gap-2">
|
||||
Recent repositories
|
||||
<Link to="/repos"
|
||||
className="ml-1 w-5 h-5 rounded border border-[#DFE1E6] text-[#5E6C84] flex items-center justify-center hover:bg-[#F4F5F7] text-xs">
|
||||
+
|
||||
</Link>
|
||||
</h2>
|
||||
<Link to="/repos" className="text-xs text-[#0052CC] hover:underline">View all</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
{reposLoading ? (
|
||||
<RepoListSkeleton />
|
||||
) : !repos?.length ? (
|
||||
<EmptyRepos />
|
||||
<div className="border border-dashed border-[#DFE1E6] rounded p-6 text-center">
|
||||
<p className="text-sm text-[#5E6C84]">No repositories yet.</p>
|
||||
<Link to="/repos" className="text-xs text-[#0052CC] hover:underline mt-1 inline-block">
|
||||
Create your first repository →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{repos.slice(0, 5).map(r => <RepoCard key={r.id} repo={r} />)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{repos.slice(0, 6).map(r => <RepoCard key={r.id} repo={r} />)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Open pull requests — across all repos */}
|
||||
{repos && repos.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-[#172B4D]">Pull requests</h2>
|
||||
</div>
|
||||
<PullRequestSummary repos={repos.map(r => ({ owner: r.ownerName, name: r.name }))} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyRepos() {
|
||||
function PullRequestSummary({ repos }: { repos: { owner: string; name: string }[] }) {
|
||||
const first = repos[0]
|
||||
const { data: prs, isLoading } = usePRs(first?.owner ?? '', first?.name ?? '')
|
||||
|
||||
if (isLoading) return <PRListSkeleton />
|
||||
|
||||
const open = prs?.filter(p => p.status === 'open') ?? []
|
||||
|
||||
if (!open.length) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 border border-dashed border-[#DFE1E6] rounded text-center gap-3">
|
||||
<svg width="40" height="40" fill="none" stroke="#97A0AF" strokeWidth="1" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[#172B4D]">No repositories yet</p>
|
||||
<p className="text-xs text-[#5E6C84] mt-1">Create your first repository to get started.</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/repos"
|
||||
className="px-4 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] min-h-[44px] flex items-center"
|
||||
>
|
||||
New repository
|
||||
</Link>
|
||||
<div className="border border-[#DFE1E6] rounded p-6 text-center bg-white">
|
||||
<p className="text-sm text-[#5E6C84]">You have no open pull requests.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{open.slice(0, 5).map(pr => (
|
||||
<Link
|
||||
key={pr.id}
|
||||
to={`/repos/${first.owner}/${first.name}/pulls/${pr.id}`}
|
||||
className="flex items-center gap-3 p-4 border border-[#DFE1E6] rounded bg-white hover:border-[#4C9AFF] hover:bg-[#FAFBFC] transition-colors"
|
||||
>
|
||||
<svg width="16" height="16" fill="none" stroke="#00875A" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[#172B4D] truncate">{pr.title}</p>
|
||||
<p className="text-xs text-[#5E6C84] mt-0.5">
|
||||
{first.name} · {pr.sourceBranch} → {pr.targetBranch}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HeroIllustration() {
|
||||
return (
|
||||
<div className="shrink-0 w-32 h-32 bg-[#DEEBFF] rounded-lg flex items-center justify-center text-[#0052CC]">
|
||||
<svg width="64" height="64" fill="none" stroke="currentColor" strokeWidth="1" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useParams, useSearchParams, Link } from 'react-router-dom'
|
||||
import { useRepo } from '../api/queries/repos'
|
||||
import { TreeBrowser } from '../components/repos/TreeBrowser'
|
||||
import { RepoListSkeleton } from '../ui/Skeleton'
|
||||
import { useRecentRepos } from '../hooks/useRecentRepos'
|
||||
|
||||
export default function RepoPage() {
|
||||
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
|
||||
@@ -10,6 +12,8 @@ export default function RepoPage() {
|
||||
const ref = searchParams.get('ref') ?? ''
|
||||
|
||||
const { data: repo, isLoading, isError } = useRepo(owner, repoName)
|
||||
const { track } = useRecentRepos()
|
||||
useEffect(() => { if (owner && repoName) track(owner, repoName) }, [owner, repoName])
|
||||
|
||||
if (isLoading) return <div className="p-6"><RepoListSkeleton /></div>
|
||||
if (isError || !repo) return <div className="p-6 text-sm text-[#DE350B]">Repository not found.</div>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useStarredRepos } from '../hooks/useStarredRepos'
|
||||
import { useRepos } from '../api/queries/repos'
|
||||
|
||||
export default function StarredPage() {
|
||||
const { starred } = useStarredRepos()
|
||||
const { data: repos } = useRepos()
|
||||
|
||||
const starredRepos = repos?.filter(r => starred.includes(`${r.ownerName}/${r.name}`)) ?? []
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6">
|
||||
<h1 className="text-xl font-semibold text-[#172B4D] mb-6">Starred repositories</h1>
|
||||
|
||||
{!starredRepos.length ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 border border-dashed border-[#DFE1E6] rounded text-center gap-3">
|
||||
<svg width="36" height="36" fill="none" stroke="#97A0AF" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[#172B4D]">No starred repositories</p>
|
||||
<p className="text-xs text-[#5E6C84] mt-1">Star repositories in the sidebar to find them here quickly.</p>
|
||||
</div>
|
||||
<Link to="/repos" className="text-xs text-[#0052CC] hover:underline">Browse repositories</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{starredRepos.map(r => (
|
||||
<Link key={r.id} to={`/repos/${r.ownerName}/${r.name}`}
|
||||
className="flex items-center gap-3 p-4 border border-[#DFE1E6] rounded bg-white hover:border-[#4C9AFF] hover:bg-[#FAFBFC] transition-colors">
|
||||
<svg width="16" height="16" fill="#F79009" viewBox="0 0 24 24">
|
||||
<path d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z"/>
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-[#0052CC]">{r.ownerName}/{r.name}</p>
|
||||
{r.description && <p className="text-xs text-[#5E6C84] truncate mt-0.5">{r.description}</p>}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user