kumabook/bebop

View on GitHub
src/sagas/popup.js

Summary

Maintainability
B
5 hrs
Test Coverage
import browser from 'webextension-polyfill';
import logger from 'kiroku';
import { delay } from 'redux-saga';
import {
  fork,
  take,
  takeEvery,
  takeLatest,
  call,
  put,
  select,
  all,
} from 'redux-saga/effects';
import {
  router,
  createHashHistory,
} from 'redux-saga-router';
import {
  getPort,
  createPortChannel,
} from '../utils/port';
import { sendMessageToActiveContentTabViaBackground } from '../utils/tabs';
import { query as queryActions } from '../actions';
import { watchKeySequence } from './key_sequence';
import { beginningOfLine } from '../cursor';

const history = createHashHistory();
const portName = `popup-${Date.now()}`;
export const port = getPort(portName);

export const debounceDelayMs = 100;

export const modeSelector      = state => state.mode;
export const candidateSelector = state => state.prev && state.prev.candidate;

export function close() {
  if (window.parent !== window) {
    window.parent.postMessage(JSON.stringify({ type: 'CLOSE' }), '*');
  } else {
    window.close();
  }
}

export function sendMessageToBackground(message) {
  return browser.runtime.sendMessage(message);
}

export function* executeAction(action, candidates) {
  if (!action || candidates.length === 0) {
    return;
  }
  try {
    const payload = { actionId: action.id, candidates };
    const message = { type: 'EXECUTE_ACTION', payload };
    yield call(sendMessageToBackground, message);
    yield call(sendMessageToActiveContentTabViaBackground, message);
  } catch (e) {
    logger.error(e);
  } finally {
    close();
  }
}

export function* responseArg(payload) {
  yield call(sendMessageToBackground, { type: 'RESPONSE_ARG', payload });
}

export function* dispatchEmptyQuery() {
  yield put({ type: 'QUERY', payload: '' });
}

export function* searchCandidates({ payload: query }) {
  yield call(delay, debounceDelayMs);
  const candidate = yield select(candidateSelector);
  const mode      = yield select(modeSelector);
  switch (mode) {
    case 'candidate': {
      const payload = yield call(sendMessageToBackground, {
        type:    'SEARCH_CANDIDATES',
        payload: query,
      });
      yield put({ type: 'CANDIDATES', payload });
      break;
    }
    case 'action': {
      const separators = [{ label: `Actions for "${candidate.label}"`, index: 0 }];
      const items      = queryActions(candidate.type, query);
      yield put({ type: 'CANDIDATES', payload: { items, separators } });
      break;
    }
    case 'arg': {
      const values = yield select(state => state.scheme.enum);
      const items = (values || []).filter(o => o.label.includes(query));
      yield put({
        type:    'CANDIDATES',
        payload: { items, separators: [] },
      });
      break;
    }
    default:
      break;
  }
}

function* watchQuery() {
  yield takeLatest('QUERY', searchCandidates);
}

function* watchPort() {
  const portChannel = yield call(createPortChannel, port);

  for (;;) {
    const { type, payload } = yield take(portChannel);
    yield put({ type, payload });
  }
}

function* watchChangeCandidate() {
  const actions = ['QUERY', 'NEXT_CANDIDATE', 'PREVIOUS_CANDIDATE'];
  yield takeEvery(actions, function* handleChangeCandidate() {
    const { index, items } = yield select(state => state.candidates);
    const candidate = items[index];
    sendMessageToActiveContentTabViaBackground({ type: 'CHANGE_CANDIDATE', payload: candidate })
      .catch(() => {});
  });
}

export function* normalizeCandidate(candidate) {
  if (!candidate) {
    return null;
  }
  if (candidate.type === 'search') {
    const q = yield select(state => state.query);
    return Object.assign({}, candidate, { args: [q] });
  }
  return Object.assign({}, candidate);
}

function getMarkedCandidates({ markedCandidateIds, items }) {
  return Object.entries(markedCandidateIds)
    .map(([k, v]) => v && items.find(i => i.id === k))
    .filter(item => item);
}

export function* getTargetCandidates({ markedCandidateIds, items, index }, needNormalize = false) {
  const marked = getMarkedCandidates({ markedCandidateIds, items });
  if (marked.length > 0) {
    return marked;
  }
  if (needNormalize) {
    return [yield normalizeCandidate(items[index])];
  }
  return [items[index]];
}

function* watchSelectCandidate() {
  yield takeEvery('SELECT_CANDIDATE', function* handleSelectCandidate({ payload }) {
    const { mode, prev } = yield select(state => state);
    let action;
    switch (mode) {
      case 'candidate': {
        const c  = yield normalizeCandidate(payload);
        [action] = queryActions(c.type);
        yield executeAction(action, [c]);
        break;
      }
      case 'action': {
        action = payload;
        const candidates = yield getTargetCandidates(prev);
        yield executeAction(action, candidates);
        break;
      }
      case 'arg': {
        const c = yield normalizeCandidate(payload);
        yield responseArg([c]);
        break;
      }
      default:
        break;
    }
  });
}

function* watchReturn() {
  yield takeEvery('RETURN', function* handleReturn({ payload: { actionIndex } }) {
    const {
      candidates: { index, items },
      mode, markedCandidateIds, prev,
    } = yield select(state => state);
    switch (mode) {
      case 'candidate': {
        const candidates = yield getTargetCandidates({ index, items, markedCandidateIds }, true);
        const actions = queryActions(candidates[0].type);
        const action  = actions[Math.min(actionIndex, actions.length - 1)];
        yield executeAction(action, candidates);
        break;
      }
      case 'action': {
        const action = items[index];
        const candidates = yield getTargetCandidates(prev);
        yield executeAction(action, candidates);
        break;
      }
      case 'arg': {
        const type = yield select(state => state.scheme.type);
        let payload = yield select(state => state.query);
        if (type === 'object') {
          payload = yield getTargetCandidates({ index, items, markedCandidateIds });
        }
        yield responseArg(payload);
        break;
      }
      default:
        break;
    }
  });
}

function* watchListActions() {
  /* eslint-disable object-curly-newline */
  yield takeEvery('LIST_ACTIONS', function* handleListActions() {
    const {
      candidates: { index, items },
      query, separators, markedCandidateIds, mode, prev,
    } = yield select(state => state);
    switch (mode) {
      case 'candidate': {
        const candidate = yield normalizeCandidate(items[index]);
        if (!candidate) {
          return;
        }
        yield put({
          type:    'SAVE_CANDIDATES',
          payload: { candidate, query, index, items, separators, markedCandidateIds },
        });
        yield call(searchCandidates, { payload: '' });
        break;
      }
      case 'action':
        yield put({ type: 'RESTORE_CANDIDATES', payload: prev });
        break;
      case 'arg':
        break;
      default:
        break;
    }
  });
}

function* watchMarkCandidate() {
  yield takeEvery('MARK_CANDIDATE', function* handleMarkCandidate() {
    const { mode, candidates: { index, items } } = yield select(state => state);
    if (mode === 'action') {
      return;
    }
    const candidate = yield normalizeCandidate(items[index]);
    yield put({ type: 'CANDIDATE_MARKED', payload: candidate });
  });
}

function* watchMarkAllCandidates() {
  yield takeEvery('MARK_ALL_CANDIDATES', function* handleMarkAllCandidates() {
    const { mode, candidates: { index, items } } = yield select(state => state);
    if (mode === 'action') {
      return;
    }
    const { type } = items[index];
    yield put({ type: 'CANDIDATES_MARKED', payload: items.filter(c => c.type === type) });
  });
}

function* watchRequestArg() {
  yield takeEvery('REQUEST_ARG', function* handleRequestArg({ payload }) {
    const { scheme: { default: defaultValue } } = payload;
    yield put({ type: 'QUERY', payload: defaultValue || '' });
    beginningOfLine();
  });
}

/**
 * Currently, we can't focus to an input form after tab changed.
 * So, we just close window.
 * If this restriction is change, we need to flag on.
 */
function* watchTabChange() {
  yield takeLatest('TAB_CHANGED', function* h({ payload = {} }) {
    if (!payload.canFocusToPopup) {
      close();
    } else {
      yield call(delay, debounceDelayMs);
      document.querySelector('.commandInput').focus();
      const query = yield select(state => state.query);
      const items = yield call(sendMessageToBackground, {
        type:    'SEARCH_CANDIDATES',
        payload: query,
      });
      yield put({ type: 'CANDIDATES', payload: items });
    }
  });
}

function* watchQuit() {
  yield takeLatest('QUIT', close);
}

function* routerSaga() {
  yield fork(router, history, {});
}

export default function* root() {
  yield all([
    fork(watchTabChange),
    fork(watchQuery),
    fork(watchKeySequence),
    fork(watchChangeCandidate),
    fork(watchSelectCandidate),
    fork(watchReturn),
    fork(watchListActions),
    fork(watchMarkCandidate),
    fork(watchMarkAllCandidates),
    fork(watchRequestArg),
    fork(watchQuit),
    fork(watchPort),
    fork(routerSaga),
    fork(dispatchEmptyQuery),
  ]);
}