Commits

Julian Brost committed 959fc2a

Implemented tabbed user interface for usersettings. Fixes #104

Comments (0)

Files changed (7)

MoinMoin/apps/frontend/views.py

     submit = String.using(default=L_('Save'), optional=True)
 
 
-@frontend.route('/+usersettings', defaults=dict(part='main'), methods=['GET'])
-@frontend.route('/+usersettings/<part>', methods=['GET', 'POST'])
-def usersettings(part):
+@frontend.route('/+usersettings', methods=['GET', 'POST'])
+def usersettings():
     # TODO use ?next=next_location check if target is in the wiki and not outside domain
     title_name = _('User Settings')
 
         results_per_page = Integer.using(label=L_('History results per page')).with_properties(placeholder=L_("Number of results per page (0=no paging)")).validated_by(ValueAtLeast(0))
         submit = String.using(default=L_('Save'), optional=True)
 
-    dispatch = dict(
+    form_classes = dict(
         personal=UserSettingsPersonalForm,
         password=UserSettingsPasswordForm,
         notification=UserSettingsNotificationForm,
         navigation=UserSettingsNavigationForm,
         options=UserSettingsOptionsForm,
     )
-    FormClass = dispatch.get(part)
-    if FormClass is None:
-        # 'main' part or some invalid part
-        return render_template('usersettings.html',
-                               part='main',
-                               title_name=title_name,
-                              )
-    if request.method == 'GET':
-        form = FormClass.from_object(flaskg.user)
-        form['submit'].set_default() # XXX from_object() kills all values
-    elif request.method == 'POST':
-        form = FormClass.from_flat(request.form)
-        if form.validate():
-            # successfully modified everything
-            success = True
-            if part == 'password':
-                flaskg.user.enc_password = crypto.crypt_password(form['password1'].value)
-                flaskg.user.save()
-                flash(_("Your password has been changed."), "info")
+    forms = dict()
+
+    if request.method == 'POST':
+        part = request.form.get('part')
+        if part not in form_classes:
+            # the current part does not exist
+            if request.is_xhr:
+                # if the request is made via XHR, we return 404 Not Found
+                abort(404)
+            # otherwise we basically fall back to a normal GET request
+            part = None
+
+        if part:
+            # create form object from request.form
+            form = form_classes[part].from_flat(request.form)
+
+            # save response to a dict as we can't use HTTP redirects or flash() for XHR requests
+            response = dict(
+                form = None,
+                flash = [],
+                redirect = None,
+            )
+
+            if form.validate():
+                # successfully modified everything
+                success = True
+                if part == 'password':
+                    flaskg.user.enc_password = crypto.crypt_password(form['password1'].value)
+                    flaskg.user.save()
+                    response['flash'].append((_("Your password has been changed."), "info"))
+                else:
+                    if part == 'personal':
+                        if form['openid'].value != flaskg.user.openid and user.search_users(openid=form['openid'].value):
+                            # duplicate openid
+                            response['flash'].append((_("This openid is already in use."), "error"))
+                            success = False
+                        if form['name'].value != flaskg.user.name and user.search_users(name_exact=form['name'].value):
+                            # duplicate name
+                            response['flash'].append((_("This username is already in use."), "error"))
+                            success = False
+                    if part == 'notification':
+                        if (form['email'].value != flaskg.user.email and
+                            user.search_users(email=form['email'].value) and app.cfg.user_email_unique):
+                            # duplicate email
+                            response['flash'].append((_('This email is already in use'), 'error'))
+                            success = False
+                    if success:
+                        user_old_email = flaskg.user.email
+                        form.update_object(flaskg.user, omit=['submit']) # don't save submit button value :)
+                        if part == 'notification' and app.cfg.user_email_verification and form['email'].value != user_old_email:
+                            # disable account
+                            flaskg.user.disabled = True
+                            # send verification mail
+                            is_ok, msg = flaskg.user.mailVerificationLink()
+                            if is_ok:
+                                _logout()
+                                flaskg.user.save()
+                                response['flash'].append((_('Your account has been disabled because you changed your email address. Please see the email we sent to your address to reactivate it.'), "info"))
+                                response['redirect'] = url_for('.show_root')
+                            else:
+                                # sending the verification email didn't work. reset email change and alert the user.
+                                flaskg.user.disabled = False
+                                flaskg.user.email = user_old_email
+                                flaskg.user.save()
+                                response['flash'].append((_('Your email address was not changed because sending the verification email failed. Please try again later.'), "error"))
+                        else:
+                            flaskg.user.save()
+
+            if not response['flash']:
+                # if no flash message was added until here, we add a generic success message
+                response['flash'].append((_("Your changes have been saved."), "info"))
+
+            if response['redirect'] is not None or not request.is_xhr:
+                # if we redirect or it is no XHR request, we just flash() the messages normally
+                for f in response['flash']:
+                    flash(*f)
+
+            if request.is_xhr:
+                # if it is a XHR request, render the part from the usersettings_ajax.html template
+                # and send the response encoded as an JSON object
+                response['form'] = render_template('usersettings_ajax.html',
+                                                   part=part,
+                                                   form=form,
+                                                  )
+                return jsonify(**response)
             else:
-                if part == 'personal':
-                    if form['openid'].value != flaskg.user.openid and user.search_users(openid=form['openid'].value):
-                        # duplicate openid
-                        flash(_("This openid is already in use."), "error")
-                        success = False
-                    if form['name'].value != flaskg.user.name and user.search_users(name_exact=form['name'].value):
-                        # duplicate name
-                        flash(_("This username is already in use."), "error")
-                        success = False
-                if part == 'notification':
-                    if (form['email'].value != flaskg.user.email and
-                        user.search_users(email=form['email'].value) and app.cfg.user_email_unique):
-                        # duplicate email
-                        flash(_('This email is already in use'), 'error')
-                        success = False
-                if success:
-                    user_old_email = flaskg.user.email
-                    form.update_object(flaskg.user, omit=['submit']) # don't save submit button value :)
-                    if part == 'notification' and app.cfg.user_email_verification and form['email'].value != user_old_email:
-                        # disable account
-                        flaskg.user.disabled = True
-                        # send verification mail
-                        is_ok, msg = flaskg.user.mailVerificationLink()
-                        if is_ok:
-                            _logout()
-                            flaskg.user.save()
-                            flash(_('Your account has been disabled because you changed your email address. Please see the email we sent to your address to reactivate it.'), "info")
-                            return redirect(url_for('.show_root'))
-                        else:
-                            # sending the verification email didn't work. reset email change and alert the user.
-                            flaskg.user.disabled = False
-                            flaskg.user.email = user_old_email
-                            flash(_('Your email address was not changed because sending the verification email failed. Please try again later.'), "error")
-                    flaskg.user.save()
-                    return redirect(url_for('.usersettings'))
-                else:
-                    # reset to valid values
-                    form = FormClass.from_object(flaskg.user)
-                    form['submit'].set_default() # XXX from_object() kills all values
-    if part == 'notification' and app.cfg.user_email_verification:
-        flash(_("Changing your email address requires you to verify it. A link will be sent to you."), "warning")
+                # if it is not a XHR request but there is an redirect pending, we use a normal HTTP redirect
+                if response['redirect'] is not None:
+                    return redirect(response['redirect'])
+
+            # if the view did not return until here, we add the current form to the forms dict
+            # and continue with rendering the normal template
+            forms[part] = form
+
+    # initialize all remaining forms
+    for p, FormClass in form_classes.iteritems():
+        if p not in forms:
+            forms[p] = FormClass.from_object(flaskg.user)
+
     return render_template('usersettings.html',
                            title_name=title_name,
-                           part=part,
-                           form=form,
+                           form_objs=forms,
                           )
 
 

MoinMoin/static/js/common.js

     input_element.val(input_element.val() + ctype_format(subitem_name, fullname));
     input_element.focus();
 }
+
+function initMoinTabs($) {
+    "use strict";
+    // find all .moin-tabs elements and initialize them
+    $('.moin-tabs').each(function () {
+        var tabs = $(this),
+            titles = $(document.createElement('ul')),
+            lastLocationHash;
+        titles.addClass('moin-tab-titles');
+
+        // switching between tabs based on the current location hash
+        function updateFromLocationHash() {
+            if (location.hash !== undefined && location.hash !== '' && tabs.children(location.hash).length) {
+                if (location.hash !== lastLocationHash) {
+                    lastLocationHash = location.hash;
+                    tabs.children('.moin-tab-body').hide();
+                    tabs.children(location.hash).show();
+                    titles.children('li').children('a').removeClass('current');
+                    titles.children('li').children('a[href="' + location.hash + '"]').addClass('current');
+                }
+            } else {
+                $(titles.children('li').children('a')[0]).click();
+            }
+        }
+
+        // move all tab titles to an <ul> at the beginning of .moin-tabs
+        tabs.children('.moin-tab-title').each(function () {
+            var li = $(document.createElement('li')),
+                a = $(this).children('a');
+            a.click(function () {
+                location.hash = this.hash;
+                updateFromLocationHash();
+                return false;
+            });
+            li.append(a);
+            titles.append(li);
+            $(this).remove();
+        });
+        tabs.prepend(titles);
+
+        updateFromLocationHash();
+        setInterval(updateFromLocationHash, 40); // there is no event for that
+    });
+}
+
+jQuery(document).ready(initMoinTabs);
+
+function initMoinUsersettings($) {
+    "use strict";
+    // save initial values of each form
+    $('#moin-usersettings form').each(function () {
+        $(this).data('initialForm', $(this).serialize());
+    });
+
+    // check if any changes were made
+    function changeHandler(ev) {
+        var form = $(ev.currentTarget),
+            title = $('.moin-tab-titles a.current', form.parentsUntil('.moin-tabs').parent()),
+            e;
+        if (form.data('initialForm') === form.serialize()) {
+            // current values are identicaly to initial ones, remove all change indicators (if any)
+            $('.change-indicator', title).remove();
+        } else {
+            // the values differ
+            if (!$('.change-indicator', title).length) {
+                // only add a change indicator if there none
+                e = $(document.createElement('span'));
+                e.addClass('change-indicator');
+                e.text('*');
+                title.append(e);
+            }
+        }
+    }
+    $('#moin-usersettings form').change(changeHandler);
+
+    function submitHandler(ev) {
+        var form = $(ev.target),
+            button = $('button', form),
+            buttonBaseText = button.html(),
+            buttonDotList = [' .&nbsp;&nbsp;', ' &nbsp;.&nbsp;', ' &nbsp;&nbsp;.'],
+            buttonDotIndex = 0,
+            buttonDotAnimation;
+
+        // disable the button
+        button.attr('disabled', true);
+
+        // remove change indicators from the current tab as we are now saving it
+        $('.moin-tab-titles a.current .change-indicator',
+                form.parentsUntil('.moin-tabs').parent()).remove();
+
+        // animate the submit button to indicating a running request
+        function buttonRunAnimation() {
+            button.html(buttonBaseText + buttonDotList[buttonDotIndex % buttonDotList.length]);
+            buttonDotIndex += 1;
+        }
+        buttonDotAnimation = setInterval(buttonRunAnimation, 500);
+        buttonRunAnimation();
+
+        // send the form to the server
+        $.post(form.attr('action'), form.serialize(), function (data) {
+            var i, f, newform;
+            clearInterval(buttonDotAnimation);
+            // if the response indicates a redirect, set the new location
+            if (data.redirect) {
+                location.href = data.redirect;
+                return;
+            }
+            // remove all flash messages previously added via javascript
+            $('#moin-header .moin-flash-javascript').remove();
+            // add new flash messages from the response
+            for (i = 0; i < data.flash.length; i += 1) {
+                f = $(document.createElement('p'));
+                f.html(data.flash[i][0]);
+                f.addClass('moin-flash');
+                f.addClass('moin-flash-javascript');
+                f.addClass('moin-flash-' + data.flash[i][1]);
+                $('#moin-header').append(f);
+            }
+            // get the new form element from the response
+            newform = $(data.form);
+            // set event handlers on the new form
+            newform.submit(submitHandler);
+            newform.change(changeHandler);
+            // store the forms initial data
+            newform.data('initialForm', newform.serialize());
+            // replace the old form with the new one
+            form.replaceWith(newform);
+        }, 'json');
+        return false;
+    }
+    $('#moin-usersettings form').submit(submitHandler);
+}
+
+jQuery(document).ready(initMoinUsersettings);

MoinMoin/templates/forms.html

   </dd>
 {% endmacro %}
 
+{% macro render_hidden(name, value) %}
+  <input type="hidden" name="{{ name }}" value="{{ value }}" />
+{% endmacro %}
+
+{% macro render_button(text) %}
+  <button>{{ text }}</button>
+{% endmacro %}
+
 {% macro render_textcha(gen, form) %}
     {% if form.textcha_question.value %}
     <dt>

MoinMoin/templates/usersettings.html

 {% extends theme("layout.html") %}
-{% import "forms.html" as forms %}
+{% import "usersettings_forms.html" as user_forms %}
 
 {% block item %}
-{% if part == 'main' %}
-<h1>{{ _("User Settings") }}</h1>
-<ul>
-    <li><a href="{{ url_for('frontend.usersettings', part='personal') }}">{{ _("Personal Settings") }}</a></li>
-    <li><a href="{{ url_for('frontend.usersettings', part='password') }}">{{ _("Change password") }}</a></li>
-    <li><a href="{{ url_for('frontend.usersettings', part='notification') }}">{{ _("Notification Settings") }}</a></li>
-    <li><a href="{{ url_for('frontend.usersettings', part='ui') }}">{{ _("Wiki Appearance Settings") }}</a></li>
-    <li><a href="{{ url_for('frontend.usersettings', part='navigation') }}">{{ _("Navigation Settings") }}</a></li>
-    <li><a href="{{ url_for('frontend.usersettings', part='options') }}">{{ _("Options") }}</a></li>
-</ul>
+<div id="moin-content">
+    <h1>{{ _("User Settings") }}</h1>
 
-{% elif part == 'personal' %}
-<h1>{{ _("Personal Settings") }}</h1>
-<div class="moin-form">
-{{ gen.form.open(form, method="post", action=url_for('frontend.usersettings', part=part)) }}
-  {{ forms.render_errors(form) }}
-  <dl>
-    {{ forms.render_field(gen, form['name'], 'text') }}
-    {{ forms.render_field(gen, form['aliasname'], 'text') }}
-    {{ forms.render_field(gen, form['openid'], 'url') }}
-    {{ forms.render_select(gen, form['timezone']) }}
-    {{ forms.render_select(gen, form['locale']) }}
-  </dl>
-  {{ gen.input(form['submit'], type='submit') }}
-{{ gen.form.close() }}
+    <div id="moin-usersettings" class="moin-tabs">
+        <h2 class="moin-tab-title"><a href="#personal">{{ _("Personal Settings") }}</a></h2>
+        <div id="personal" class="moin-tab-body moin-form">
+            {{ user_forms.personal(form_objs.personal) }}
+        </div>
+
+        <h2 class="moin-tab-title"><a href="#password">{{ _("Change Password") }}</a></h2>
+        <div id="password" class="moin-tab-body moin-form">
+            {{ user_forms.password(form_objs.password) }}
+        </div>
+
+        <h2 class="moin-tab-title"><a href="#notification">{{ _("Notification Settings") }}</a></h2>
+        <div id="notification" class="moin-tab-body moin-form">
+            {{ user_forms.notification(form_objs.notification) }}
+        </div>
+
+        <h2 class="moin-tab-title"><a href="#ui">{{ _("Wiki Appearance Settings") }}</a></h2>
+        <div id="ui" class="moin-tab-body moin-form">
+            {{ user_forms.ui(form_objs.ui) }}
+        </div>
+
+        <h2 class="moin-tab-title"><a href="#navigation">{{ _("Navigation Settings") }}</a></h2>
+        <div id="navigation" class="moin-tab-body moin-form">
+            {{ user_forms.navigation(form_objs.navigation) }}
+        </div>
+
+        <h2 class="moin-tab-title"><a href="#options">{{ _("Options") }}</a></h2>
+        <div id="options" class="moin-tab-body moin-form">
+            {{ user_forms.options(form_objs.options) }}
+        </div>
+    </div>
 </div>
 
-{% elif part == 'password' %}
-<h1>{{ _("Change Password") }}</h1>
-<div class="moin-form">
-{{ gen.form.open(form, method="post", action=url_for('frontend.usersettings', part=part)) }}
-  {{ forms.render_errors(form) }}
-  <dl>
-    {{ forms.render_field(gen, form['password_current'], 'password') }}
-    {{ forms.render_field(gen, form['password1'], 'password') }}
-    {{ forms.render_field(gen, form['password2'], 'password') }}
-  </dl>
-  {{ gen.input(form['submit'], type='submit') }}
-{{ gen.form.close() }}
-</div>
-
-{% elif part == 'notification' %}
-<h1>{{ _("Notification Settings") }}</h1>
-<div class="moin-form">
-{{ gen.form.open(form, method="post", action=url_for('frontend.usersettings', part=part)) }}
-  {{ forms.render_errors(form) }}
-  <dl>
-    {{ forms.render_field(gen, form['email'], 'email') }}
-  </dl>
-  {{ gen.input(form['submit'], type='submit') }}
-{{ gen.form.close() }}
-</div>
-
-{% elif part == 'ui' %}
-<h1>{{ _("Wiki Appearance Settings") }}</h1>
-<div class="moin-form">
-{{ gen.form.open(form, method="post", action=url_for('frontend.usersettings', part=part)) }}
-  {{ forms.render_errors(form) }}
-  <dl>
-    {{ forms.render_select(gen, form['theme_name']) }}
-    {{ forms.render_field(gen, form['css_url'], 'url') }}
-    {{ forms.render_field(gen, form['edit_rows'], 'text') }}
-    {{ forms.render_field(gen, form['results_per_page'], 'number') }}
-  </dl>
-  {{ gen.input(form['submit'], type='submit') }}
-{{ gen.form.close() }}
-</div>
-
-{% elif part == 'navigation' %}
-<h1>{{ _("Navigation Settings") }}</h1>
-<div class="moin-form">
-{{ gen.form.open(form, method="post", action=url_for('frontend.usersettings', part=part)) }}
-  {{ forms.render_errors(form) }}
-  <dl>
-    {# TODO: find a good way to handle quicklinks #}
-  </dl>
-  {{ gen.input(form['submit'], type='submit') }}
-{{ gen.form.close() }}
-</div>
-
-{% elif part == 'options' %}
-<h1>{{ _("Options") }}</h1>
-<div class="moin-form">
-{{ gen.form.open(form, method="post", action=url_for('frontend.usersettings', part=part)) }}
-  {{ forms.render_errors(form) }}
-  <dl>
-    {{ forms.render_field(gen, form['mailto_author'], 'checkbox') }}
-    {{ forms.render_field(gen, form['edit_on_doubleclick'], 'checkbox') }}
-    {{ forms.render_field(gen, form['show_comments'], 'checkbox') }}
-    {{ forms.render_field(gen, form['disabled'], 'checkbox') }}
-  </dl>
-  {{ gen.input(form['submit'], type='submit') }}
-{{ gen.form.close() }}
-</div>
-{% endif %}
 {% endblock %}
 

MoinMoin/templates/usersettings_ajax.html

+{% import "usersettings_forms.html" as user_forms %}
+
+{% if part == 'personal' %}
+    {{ user_forms.personal(form) }}
+{% elif part == 'password' %}
+    {{ user_forms.password(form) }}
+{% elif part == 'notification' %}
+    {{ user_forms.notification(form) }}
+{% elif part == 'ui' %}
+    {{ user_forms.ui(form) }}
+{% elif part == 'navigation' %}
+    {{ user_forms.navigation(form) }}
+{% elif part == 'options' %}
+    {{ user_forms.options(form) }}
+{% endif %}

MoinMoin/templates/usersettings_forms.html

+{% import "forms.html" as forms %}
+
+{% macro personal(form) %}
+{{ gen.form.open(form, method="post", action=url_for('frontend.usersettings')) }}
+{{ forms.render_errors(form) }}
+<dl>
+    {{ forms.render_field(gen, form['name'], 'text') }}
+    {{ forms.render_field(gen, form['aliasname'], 'text') }}
+    {{ forms.render_field(gen, form['openid'], 'url') }}
+    {{ forms.render_select(gen, form['timezone']) }}
+    {{ forms.render_select(gen, form['locale']) }}
+</dl>
+{{ forms.render_hidden('part', 'personal') }}
+{{ forms.render_button(_("Save")) }}
+{{ gen.form.close() }}
+{% endmacro %}
+
+
+{% macro password(form) %}
+{{ gen.form.open(form, method="post", action=url_for('frontend.usersettings')) }}
+{{ forms.render_errors(form) }}
+<dl>
+    {{ forms.render_field(gen, form['password_current'], 'password') }}
+    {{ forms.render_field(gen, form['password1'], 'password') }}
+    {{ forms.render_field(gen, form['password2'], 'password') }}
+</dl>
+{{ forms.render_hidden('part', 'password') }}
+{{ forms.render_button(_("Change password")) }}
+{{ gen.form.close() }}
+{% endmacro %}
+
+{% macro notification(form) %}
+{{ gen.form.open(form, method="post", action=url_for('frontend.usersettings')) }}
+{% if cfg.user_email_verification %}
+<p>{{ _("Changing your email address requires you to verify it. A link will be sent to you.") }}</p>
+{% endif %}
+{{ forms.render_errors(form) }}
+<dl>
+    {{ forms.render_field(gen, form['email'], 'email') }}
+</dl>
+{{ forms.render_hidden('part', 'notification') }}
+{{ forms.render_button(_("Save")) }}
+{{ gen.form.close() }}
+{% endmacro %}
+
+{% macro ui(form) %}
+{{ gen.form.open(form, method="post", action=url_for('frontend.usersettings')) }}
+{{ forms.render_errors(form) }}
+<dl>
+    {{ forms.render_select(gen, form['theme_name']) }}
+    {{ forms.render_field(gen, form['css_url'], 'url') }}
+    {{ forms.render_field(gen, form['edit_rows'], 'text') }}
+    {{ forms.render_field(gen, form['results_per_page'], 'number') }}
+</dl>
+{{ forms.render_hidden('part', 'ui') }}
+{{ forms.render_button(_("Save")) }}
+{{ gen.form.close() }}
+{% endmacro %}
+
+{% macro navigation(form) %}
+{{ gen.form.open(form, method="post", action=url_for('frontend.usersettings')) }}
+{{ forms.render_errors(form) }}
+<dl>
+    {# TODO: find a good way to handle quicklinks #}
+</dl>
+{{ forms.render_hidden('part', 'navigation') }}
+{{ forms.render_button(_("Save")) }}
+{{ gen.form.close() }}
+{% endmacro %}
+
+{% macro options(form) %}
+{{ gen.form.open(form, method="post", action=url_for('frontend.usersettings')) }}
+{{ forms.render_errors(form) }}
+<dl>
+    {{ forms.render_field(gen, form['mailto_author'], 'checkbox') }}
+    {{ forms.render_field(gen, form['edit_on_doubleclick'], 'checkbox') }}
+    {{ forms.render_field(gen, form['show_comments'], 'checkbox') }}
+    {{ forms.render_field(gen, form['disabled'], 'checkbox') }}
+</dl>
+{{ forms.render_hidden('part', 'options') }}
+{{ forms.render_button(_("Save")) }}
+{{ gen.form.close() }}
+{% endmacro %}

MoinMoin/themes/modernized/static/css/common.css

 .moin-form dd input { width: 20em; }
 .moin-form dt label.required:after { content: '*'; color: gray; }
 
+/* tabs on user settings page */
+.moin-tab-titles { margin: 0; padding: -10px 0 0; list-style: none; border-bottom: 3px solid #4D7DA9; }
+.moin-tab-titles li { display: inline-block; margin: 10px 0 -3px; padding: 0 5px; border-bottom: 3px solid #4D7DA9; }
+.moin-tab-titles a { display: inline-block; padding: 4px; background-color: #81BBF2; border-width: 1px 1px 0;
+            border-style: solid; border-color: #4D7DA9; color: #222 !important; }
+.moin-tab-titles a:hover { background-color: #4D7DA9; text-decoration: none; }
+.moin-tab-titles a.current { background: #4D7DA9; padding-top: 8px; margin-top: -4px; }
+.moin-tab-titles .change-indicator { font-weight: bold; color: #D22; }
+.moin-tab-title a { color: #000 !important; text-decoration: none !important; }
+
 /* Recent changes */
 .rcrss { float: right; margin: 0 7px 0 14px; height: 0; position: relative; top: 9px; }
 .recentchanges table { clear: both; border-collapse: collapse; border: 1px solid #4D7DA9; }
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.